diff --git a/.superset/lib/setup/steps.sh b/.superset/lib/setup/steps.sh index 769e6d32839..51ac6f0760f 100644 --- a/.superset/lib/setup/steps.sh +++ b/.superset/lib/setup/steps.sh @@ -478,18 +478,18 @@ step_write_env() { echo "" echo "# Electric URLs (overrides from root .env)" write_env_var "ELECTRIC_URL" "http://localhost:$ELECTRIC_PORT/v1/shape" - echo "# Caddy HTTPS proxy for HTTP/2 (avoids browser 6-connection limit with 10+ SSE streams)" - write_env_var "NEXT_PUBLIC_ELECTRIC_URL" "https://localhost:$CADDY_ELECTRIC_PORT/api/electric" - write_env_var "NEXT_PUBLIC_ELECTRIC_PROXY_URL" "https://localhost:$CADDY_ELECTRIC_PORT/api/electric" + echo "# Caddy HTTPS proxy for HTTP/2 (avoids browser 6-connection limit with Electric SSE streams)" + write_env_var "NEXT_PUBLIC_ELECTRIC_URL" "https://localhost:$CADDY_ELECTRIC_PORT" + write_env_var "NEXT_PUBLIC_ELECTRIC_PROXY_URL" "https://localhost:$CADDY_ELECTRIC_PORT" } >> .env success "Workspace .env written" # Generate Caddyfile for HTTP/2 reverse proxy (avoids browser 6-connection limit with Electric SSE streams) - # Caddy proxies to the API server which handles auth and forwards to Electric Docker + # Caddy proxies to the local Wrangler worker, which handles auth and forwards upstream appropriately. cat > Caddyfile <<-CADDYEOF https://localhost:{\$CADDY_ELECTRIC_PORT} { - reverse_proxy localhost:{\$API_PORT} { + reverse_proxy localhost:{\$WRANGLER_PORT} { flush_interval -1 } } @@ -522,7 +522,8 @@ PORTSJSON cat > apps/electric-proxy/.dev.vars < Mosaic layout of Pane leaves`, backed by one global persisted store in app-state. +- The desktop renderer already uses TanStack collections for: + - Electric-backed org/shared data + - localStorage-backed per-org UI state such as dashboard sidebar project/workspace/section ordering +- The main process reads the legacy persisted tabs state for notification/pane resolution, which is one of the main reasons the old model is so coupled. + + +## Recommendation + +### 1. Persistence story + +Use **TanStack DB localStorage collections** for the authoritative persisted v2 pane layout, but only for **device-local, restoreable UI state**. + +Do **not** use Electric/server-backed collections for panes yet. + +Reasons: + +- Pane layout is device-local. A terminal split setup on one device is not obviously correct on another device. +- Several pane kinds are fundamentally local-runtime objects: + - terminal sessions + - embedded browser webContents + - devtools targets +- The renderer already has a clean pattern for local per-org UI persistence with `localStorageCollectionOptions`. +- A collection-based layout model is easier to query and evolve than another large persisted Zustand blob. + +Do **not** persist everything. Split pane state into: + +- `persisted` + - split tree + - group ordering + - active pane ids + - restoreable pane descriptors + - lightweight view state that can be restored +- `runtime` + - live terminal socket/session attachment state + - browser loading/error/back-forward stack if we do not explicitly want session restore + - unsaved editor buffer state + - drag state / hover state / temporary preview transitions + +### 2. Main-process access + +Do not make the main-process app-state blob the source of truth for v2 panes. + +Instead: + +- Keep the authoritative persisted model in local TanStack collections +- Keep runtime registries in renderer/main for live objects +- Only mirror the **minimal lookup surface** into main if a specific feature needs it + +Examples of minimal mirrored state: + +- `workspaceId -> activePaneId` +- `chat sessionId -> paneId` +- `browser paneId -> target devtools paneId` + +This avoids recreating the legacy coupling where every pane mutation must serialize a full snapshot into main-process app-state. + +### 3. Suggested collections + +Start with one persisted layout document per workspace. + +Recommended collection: + +- `v2WorkspacePaneLayouts` + - storage key: `v2-workspace-pane-layouts-${organizationId}` + - row key: `workspaceId` + +Why one document per workspace: + +- Pane updates are naturally workspace-scoped +- Restore/load is always by workspace +- Split tree + groups + panes are one consistency boundary +- Simpler migration story than multiple interdependent collections + +If this becomes too coarse later, it can be split into `groups` and `panes` collections without changing the conceptual model. + + +## UI model + +Use a VS Code-like model: + +- A workspace has a **split tree** +- Leaves of the split tree are **pane groups** +- Each group has an ordered list of **pane tabs** +- Each group has one active pane +- The workspace has one active group / focused pane + +This is meaningfully different from the legacy model: + +- Legacy: tab contains a mosaic of panes +- Proposed: group is the split leaf, and panes are tabs within a group + +That matches the mental model users already know from VS Code: + +- split editor right/down +- move tab to group +- drag a tab to create a new group +- merge groups by dragging into a tab strip + +### Proposed persisted shape + +```ts +type WorkspacePaneLayoutDocument = { + workspaceId: string; + version: 1; + root: PaneNode; + groups: Record; + panes: Record; + activeGroupId: string | null; + lastFocusedPaneId: string | null; + createdAt: Date; + updatedAt: Date; +}; + +type PaneNode = + | { type: "group"; groupId: string } + | { + type: "split"; + direction: "horizontal" | "vertical"; + first: PaneNode; + second: PaneNode; + ratio: number; + }; + +type PaneGroup = { + id: string; + paneIds: string[]; + activePaneId: string | null; + previewPaneId: string | null; +}; +``` + +### Why groups instead of persisting Mosaic leaves directly + +- The model reads like the product behavior +- It maps well to custom rendering or `react-mosaic-component` +- Pane-group operations become explicit +- Preview tab behavior is group-local, which is how VS Code works + + +## Pane type model + +The current `PaneType` union is directionally right, but it is too flat if we want good restore semantics. + +Recommended shift: + +- keep a small top-level `kind` +- separate `input` from `viewState` +- keep runtime-only data out of the persisted pane record + +### Suggested terminology + +Rename: + +- `"webview"` -> `"browser"` if this pane is really the in-app browser +- `"file-viewer"` -> `"file"` or `"editor"` + +`webview` and `file-viewer` describe implementation details, not product concepts. + +Suggested base union: + +```ts +type PaneKind = "terminal" | "browser" | "file" | "chat" | "devtools"; + +type PersistedPane = + | TerminalPane + | BrowserPane + | FilePane + | ChatPane + | DevtoolsPane; + +type PaneBase = { + id: string; + kind: K; + title?: string; + isPinned: boolean; + createdAt: number; + updatedAt: number; + input: Input; + viewState?: ViewState; +}; +``` + +### File pane + +```ts +type FilePane = PaneBase< + "file", + { + path: string; + mode: "editor" | "diff" | "preview"; + comparePath?: string; + compareCommit?: string; + }, + { + line?: number; + column?: number; + scrollTop?: number; + } +>; +``` + +Notes: + +- This should be the main preview-tab candidate +- `mode` is part of the pane input, not the pane kind +- Later we can add markdown preview or image preview without inventing a new top-level kind + +### Terminal pane + +```ts +type TerminalPane = PaneBase< + "terminal", + { + sessionKey: string; + cwd?: string; + launchMode: "workspace-shell" | "command" | "agent"; + command?: string; + } +>; +``` + +Notes: + +- Persist the terminal identity, not the PTY internals +- Restore by `attachOrCreate(sessionKey)` +- Terminal buffer and websocket state stay runtime-only + +### Browser pane + +```ts +type BrowserPane = PaneBase< + "browser", + { + url: string; + mode?: "docs" | "preview" | "generic"; + }, + { + viewportPresetId?: string | null; + } +>; +``` + +Notes: + +- Persist current URL and maybe viewport +- Do not persist the full back/forward stack in v1 unless there is a strong UX reason + +### Chat pane + +```ts +type ChatPane = PaneBase< + "chat", + { + sessionId: string | null; + }, + { + draftId?: string | null; + } +>; +``` + +Notes: + +- Persist the chat session identity +- Composer draft can be separate if we want independent retention rules +- Launch config should remain transient unless we explicitly want “restore unfinished launch” + +### Devtools pane + +```ts +type DevtoolsPane = PaneBase< + "devtools", + { + targetPaneId: string; + } +>; +``` + +Notes: + +- This is the least durable pane type +- On restore, drop it if the target browser pane no longer exists +- It may even make sense to mark devtools as non-restorable initially + + +## Behavioral rules + +### Preview behavior + +Use VS Code-like preview semantics, but only where they make sense. + +Suggested rule: + +- file panes can open as preview +- browser panes can optionally reuse a preview pane +- chat, terminal, and devtools are always pinned + +This keeps preview replacement from feeling destructive for long-lived panes. + +### Group operations + +Support these first: + +1. open pane in current group +2. split current group right/down +3. move pane to existing group +4. drag pane to edge to create group +5. close pane +6. close group if empty + +Do not start with more advanced VS Code behaviors like orthogonal nested drop overlays everywhere. The important thing is the state model, not perfect parity on day one. + +### Restore rules + +On workspace open: + +1. load `v2WorkspacePaneLayouts[workspaceId]` +2. validate and normalize +3. rehydrate visible groups and active panes +4. lazily attach live runtimes for terminal/browser/chat panes when their group becomes active or visible + +This avoids doing expensive restores for every hidden pane immediately. + + +## What not to do + +- Do not reuse the legacy global tabs store shape for v2 +- Do not make panes Electric-synced yet +- Do not persist volatile runtime state just because it exists in memory +- Do not encode behavior into `PaneType` names when it belongs in `input.mode` + + +## Implementation plan + +### Phase 1: types and persistence + +1. Add a new v2 pane model under the v2 workspace route/store code, separate from legacy `shared/tabs-types.ts` +2. Add `v2WorkspacePaneLayouts` localStorage collection +3. Add normalization helpers for missing groups, orphan panes, invalid active ids + +### Phase 2: renderer store + +Use a small local store for runtime state and commands: + +- focused group id +- drag state +- live terminal/browser attachment state +- imperative actions like split, move, close, focus + +The store should read/write the persisted layout document, but it should not own persistence itself. + +### Phase 3: first pane kinds + +Implement in this order: + +1. file +2. terminal +3. chat +4. browser +5. devtools + +That order gives the highest-value workspace behavior first and defers the trickiest host-coupled panes. + +### Phase 4: bridge runtime services + +Add small adapters: + +- terminal pane -> attach/create session by `sessionKey` +- browser pane -> create/restore browser surface for `url` +- chat pane -> bind to `sessionId` +- devtools pane -> attach to target browser pane if present + + +## Decision summary + +- Use **TanStack DB localStorage collections** for persisted v2 pane layouts +- Keep panes **device-local** for now +- Make the persisted model **workspace-scoped** +- Model the UI as **split tree -> groups -> pane tabs** +- Redefine pane types around **kind + input + viewState** +- Keep runtime state separate and only mirror minimal lookup data into main if needed diff --git a/apps/desktop/plans/20260322-2305-pane-layout-api-and-test-plan.md b/apps/desktop/plans/20260322-2305-pane-layout-api-and-test-plan.md new file mode 100644 index 00000000000..34498d30a23 --- /dev/null +++ b/apps/desktop/plans/20260322-2305-pane-layout-api-and-test-plan.md @@ -0,0 +1,923 @@ +# Pane Layout API And Test Plan + +## Goal + +Define a shippable Superset pane-layout package before we continue implementation. + +This package must support: + +1. Multiple roots +2. A VS Code-like layout inside each root +3. Cross-root drag and drop +4. A shared engine usable from both web and Electron +5. Both an API layer and a React component layer + +The current spike package is **not** the contract. It was useful to surface the missing top-level abstraction: `roots`. + +## Product Requirements + +### Required + +- Multiple workbench roots rendered as top-level tabs +- Each root owns a split layout made of pane groups +- Each group owns multiple pane tabs +- One active group per root +- One active pane per group +- Dragging a pane over another root tab activates that root and lets the drag continue there +- Advanced docking previews +- Renderer layer must be design-system-native, using Tailwind + `@superset/ui` + +### Nice Later + +- Floating utility panes inside the pane-layout engine +- Full keyboard command parity with VS Code + +### Non-Goals For V1 + +- Generic third-party docking framework compatibility +- Reproducing every FlexLayout or Mosaic feature +- Edge/border tabsets +- Arbitrary free-floating panes +- Detached/popout windows + +## Package Shape + +Use one workspace package: + +- `@superset/pane-layout` + +It should expose two layers: + +- `core` + - pure TypeScript types + - reducer/state transitions + - persistence/serialization helpers + - drop target and geometry helpers +- `react` + - top-level root tab strip component + - per-root layout component + - group/tab chrome built from `@superset/ui` + - drop target visuals and drag affordances + +The package must remain platform-agnostic: + +- no Electron imports +- no OS-window orchestration baked into core +- no desktop route assumptions + +Electron and web should both be able to render the same `PaneWorkspaceState`. + +## Store Strategy + +This package is internal to Superset. It does not need an external-consumer-neutral API. + +So the recommended integration surface is: + +- Zustand-first store API +- pure reducer/helpers underneath + +That gives us: + +- ergonomic React usage +- selectors and subscriptions +- easier adoption across web and desktop teams +- deterministic tests against pure state transitions + +Recommended layering: + +- `createPaneWorkspaceStore(...)` +- `usePaneWorkspaceStore(...)` +- internal pure helpers: + - `reducePaneWorkspace(...)` + - `buildRuntimeIndexes(...)` + - `normalizePaneWorkspace(...)` + - `serializePaneWorkspace(...)` + +The reducer/helpers should remain implementation details that the Zustand store delegates to. + +## Core Model + +There are two nested models: + +1. `PaneWorkspaceState` +2. `PaneRootState` + +### Workspace Layer + +This is the top-level persisted state object. + +```ts +type PersistedPaneWorkspaceState = { + version: 1; + roots: PaneRootState[]; + activeRootId: string | null; +}; +``` + +This layer owns: + +- root membership +- active root +- serialization of the whole workbench session + +It does **not** own transient drag state. + +### Root Layer + +Each root contains a VS Code-like pane layout. + +```ts +type PaneRootState = { + id: string; + root: PaneLayoutNode; + activeGroupId: string | null; +}; +``` + +### Layout Tree + +```ts +type PaneLayoutNode = + | { + type: "group"; + id: string; + activePaneId: string | null; + panes: PaneState[]; + } + | { + type: "split"; + id: string; + direction: "horizontal" | "vertical"; + sizes: number[]; + children: PaneLayoutNode[]; + }; +``` + +Notes: + +- Use an n-ary split tree, not a fixed binary tree. +- `sizes.length` must equal `children.length`. +- This gives us room to support balanced groups and future reflow without rewriting the model. +- The public durable model should stay tree-first. +- Group membership should be inline with the group node, not split into separate top-level records. +- Group preview state should be derivable from pane metadata rather than stored separately. + +### Pane Layer + +```ts +type PaneState = { + id: string; + kind: string; + titleOverride?: string; + pinned?: boolean; + data: TPaneData; +}; +``` + +The engine should not know terminal/browser/file semantics. Those stay in app-specific pane data. + +`titleOverride` is optional. Default pane titles should be derived by the renderer or adapter layer from pane data. + +## Runtime Indexes + +After digging into FlexLayout and Mosaic internals, the right split is: + +- persisted/public model: tree-first +- runtime engine: derived indexes for fast lookup + +That is effectively how the reference libraries work: + +- FlexLayout persists nested row/tabset/tab JSON, but maintains an internal `idMap`, window registry, and active-tabset tracking at runtime. +- Mosaic keeps the tree as the source of truth, but its update utilities rely heavily on path-based traversal and derived geometry helpers. + +So for us, the engine should be allowed to derive and cache: + +```ts +type RuntimeIndexes = { + groupPathById: Map; + paneLocationById: Map; +}; +``` + +These should be recomputed from the tree or incrementally maintained by the reducer, but they should not define the public contract. + +## Zustand Store Contract + +Recommended store state: + +```ts +type PaneWorkspaceStoreState = { + persisted: PersistedPaneWorkspaceState; +}; +``` + +Recommended constructor: + +```ts +function createPaneWorkspaceStore(args: { + initialPersistedState: PersistedPaneWorkspaceState; +}): StoreApi>; +``` + +Recommended public store shape: + +```ts +type PaneWorkspaceStore = PaneWorkspaceStoreState & { + setPersistedState: ( + next: + | PersistedPaneWorkspaceState + | (( + prev: PersistedPaneWorkspaceState, + ) => PersistedPaneWorkspaceState) + ) => void; + + rehydrate: (state: PersistedPaneWorkspaceState) => void; + + setActiveRoot: (rootId: string) => void; + setActiveGroup: (args: { rootId: string; groupId: string }) => void; + setActivePane: (args: { + rootId: string; + groupId: string; + paneId: string; + }) => void; + + splitGroup: (args: { + rootId: string; + groupId: string; + position: "top" | "right" | "bottom" | "left"; + newPane: PaneState; + selectNewPane?: boolean; + }) => void; + + addPaneToGroup: (args: { + rootId: string; + groupId: string; + pane: PaneState; + index?: number; + select?: boolean; + }) => void; + + closePane: (args: { + rootId: string; + groupId: string; + paneId: string; + }) => void; + + movePane: (args: { + paneId: string; + targetRootId: string; + targetGroupId: string; + index?: number; + select?: boolean; + }) => void; + + resizeSplit: (args: { + rootId: string; + splitId: string; + sizes: number[]; + }) => void; +}; +``` + +## Selectors + +We should provide a small selector layer because most consumers should not walk the tree manually. + +Recommended selectors: + +```ts +getRoot(rootId) +getActiveRoot() +getGroup(rootId, groupId) +getActiveGroup(rootId) +getPane(paneId) +getPaneLocation(paneId) +``` + +These can be exported as plain helper functions or prebuilt hooks. + +## Drag And Drop Boundary + +The core package does not need a first-class drag session. + +Because roots are top-level tabs in one React tree, the renderer can own transient drag interaction through a DnD adapter such as `react-dnd`. + +That means: + +- hover state and overlay previews live in the renderer drag layer +- the persisted layout tree does not mutate during hover +- most pane/group components should stay stable during drag +- the core only owns final mutations and shared drop-target math + +### What Lives In The Renderer Drag Layer + +- pointer/drag sensors +- drag source wiring +- drop target wiring +- group/root hit-testing +- drag preview image +- overlay rendering +- translating geometry into semantic `DropTarget` + +### What Lives In Store State + +- persisted layout +- active root +- active groups and panes +- final mutations like `movePane(...)`, `splitGroup(...)`, and `closePane(...)` + +### Why This Split Is Necessary + +- Root activation on drag hover is product behavior, not native browser behavior. +- The renderer needs a stable source of truth for docking preview overlays. +- We do not want to mutate and rerender the actual layout tree on every hover frame. +- The same final drop behavior still needs deterministic reducer coverage. + +### Required Drag Flow + +1. `dragstart` + - renderer starts a DnD gesture with the dragged pane id and source metadata + +2. `dragenter` on a root tab + - renderer can call `setActiveRoot(rootId)` to switch visible root + +3. `dragenter` or `dragover` on a group target + - renderer computes the semantic `DropTarget` + - overlay components render the preview for that target + +4. `drop` + - renderer resolves the final operation from the `DropTarget` + - renderer calls the matching mutation, typically `movePane(...)` or `splitGroup(...)` + +5. `dragend` + - renderer clears any local overlay state + +### Important Clarification + +The store should not directly manipulate DOM drag events or persist transient hover state. + +The renderer adapter is responsible for: + +- hit-testing and converting geometry into `DropTarget` +- calling the correct store methods on drop +- rendering preview overlays from drag-layer state + +The store is responsible for: + +- maintaining the persisted layout model +- exposing final mutations +- exposing shared geometry helpers for drop-target resolution when useful + +## Drop Target Model + +The core should still define the semantic drop result type used by the renderer: + +```ts +type DropTarget = + | { type: "group-center"; rootId: string; groupId: string } + | { + type: "split"; + rootId: string; + groupId: string; + position: "top" | "right" | "bottom" | "left"; + }; +``` + +Important rules: + +- Hovering another root can update `activeRootId` before drop completes. +- Hovering groups in any root must produce a visible docking preview target. +- The preview overlay should be the only thing that changes during hover; the real pane layout stays in place until drop. +- Drag/drop actions should target stable ids, not only paths, because paths are too volatile during multi-step edits and cross-root moves. + +That final drop behavior should be driven by reducer actions, not by ad hoc renderer state. + +## App Shell Boundary + +Floating utility panes are out of scope for pane-layout v1. + +For now, utility surfaces should live outside the pane-layout engine in the surrounding app shell, for example: + +- sidebars +- inspectors +- bottom utility regions +- fixed toolbars + +That remains a good assumption as long as those surfaces do not need to: + +- move between roots +- dock into pane groups +- persist inside the same pane-layout model + +If we ever need those behaviors, utility-pane support can be added later as a separate extension of the core model. + +## Required Reducer Actions + +### Workspace Actions + +- `addRoot` +- `removeRoot` +- `setActiveRoot` +- `renameRoot` +- `rehydrateWorkspace` + +### Group/Pane Actions + +- `setActiveGroup` +- `setActivePane` +- `addPaneToGroup` +- `closePane` +- `movePaneToGroup` +- `movePaneToRoot` +- `splitGroup` +- `mergeGroups` +- `resizeSplit` + +## React Component API + +The React layer should not hide the model. It should render it. + +Recommended exports: + +```ts +type PaneWorkspaceProps = { + state: PaneWorkspaceState; + dispatch: (action: PaneLayoutAction) => void; + registry: PaneRegistry; + renderRootTab?: (args: PaneRootTabRenderArgs) => ReactNode; + renderGroupToolbar?: (args: PaneGroupToolbarRenderArgs) => ReactNode; +}; +``` + +```ts +type PaneRootProps = { + root: PaneRootState; + isActive: boolean; + dispatch: (action: PaneLayoutAction) => void; + registry: PaneRegistry; +}; +``` + +### Renderer Components + +The first pass should use a smaller, clearer renderer tree: + +- `PaneWorkspace` +- `PaneRootTabs` +- `PaneRootView` +- `PaneNode` +- `PaneGroup` +- `PaneTabStrip` +- `PaneSurface` +- `PaneContent` +- `PaneEmptyState` + +The package can still export lower-level internal pieces, but the public component API should match the actual render tree instead of pre-optimizing every subpart as a replaceable primitive. + +### Exact Component Props + +```ts +type PaneWorkspaceProps = { + state: PersistedPaneWorkspaceState; + dispatch: PaneDispatch; + registry: PaneRegistry; + className?: string; + activeDropTarget?: DropTarget | null; + renderRootTab?: (args: PaneRootTabRenderArgs) => ReactNode; + renderGroupToolbar?: (args: PaneGroupToolbarRenderArgs) => ReactNode; + renderEmptyState?: (args: PaneEmptyStateRenderArgs) => ReactNode; +}; + +type PaneRootTabsProps = { + roots: PaneRootState[]; + activeRootId: string | null; + dispatch: PaneDispatch; + registry: PaneRegistry; + className?: string; + renderRootTab?: (args: PaneRootTabRenderArgs) => ReactNode; +}; + +type PaneRootViewProps = { + root: PaneRootState; + isActive: boolean; + dispatch: PaneDispatch; + registry: PaneRegistry; + className?: string; + activeDropTarget?: DropTarget | null; + renderGroupToolbar?: (args: PaneGroupToolbarRenderArgs) => ReactNode; + renderEmptyState?: (args: PaneEmptyStateRenderArgs) => ReactNode; +}; + +type PaneNodeProps = { + root: PaneRootState; + node: PaneLayoutNode; + dispatch: PaneDispatch; + registry: PaneRegistry; + activeDropTarget: DropTarget | null; + renderToolbar?: (args: PaneGroupToolbarRenderArgs) => ReactNode; + renderEmptyState?: (args: PaneEmptyStateRenderArgs) => ReactNode; +}; + +type PaneGroupProps = { + root: PaneRootState; + group: Extract, { type: "group" }>; + dispatch: PaneDispatch; + registry: PaneRegistry; + isFocused: boolean; + activeDropTarget: DropTarget | null; + renderToolbar?: (args: PaneGroupToolbarRenderArgs) => ReactNode; + renderEmptyState?: (args: PaneEmptyStateRenderArgs) => ReactNode; +}; + +type PaneTabStripProps = { + root: PaneRootState; + group: Extract, { type: "group" }>; + dispatch: PaneDispatch; + registry: PaneRegistry; +}; + +type PaneTabButtonProps = { + root: PaneRootState; + group: Extract, { type: "group" }>; + pane: PaneState; + dispatch: PaneDispatch; + registry: PaneRegistry; + isActive: boolean; + isHovered: boolean; +}; + +type PaneSurfaceProps = { + root: PaneRootState; + group: Extract, { type: "group" }>; + pane: PaneState; + dispatch: PaneDispatch; + registry: PaneRegistry; + isActive: boolean; + isFocused: boolean; + activeDropTarget: DropTarget | null; +}; + +type PaneContentProps = { + root: PaneRootState; + group: Extract, { type: "group" }>; + pane: PaneState; + dispatch: PaneDispatch; + registry: PaneRegistry; + isActive: boolean; + isFocused: boolean; +}; + +type PaneEmptyStateProps = { + root: PaneRootState; + groupId?: string; + dispatch: PaneDispatch; + registry: PaneRegistry; +}; +``` + +### Pane Definition Registry + +Pane-specific UI should be registered by pane `kind`, not stored in persisted pane state. + +```ts +type PaneRegistry = Record>; + +type PaneDefinition = { + renderPane: (args: PaneRenderArgs) => ReactNode; + getTitle?: (pane: PaneState) => string; + getIcon?: (pane: PaneState) => ReactNode; + renderHeaderActions?: (args: PaneHeaderActionsRenderArgs) => ReactNode; + renderTabActions?: (args: PaneTabActionsRenderArgs) => ReactNode; + renderEmptyState?: (args: PaneEmptyStateRenderArgs) => ReactNode; +}; +``` + +This is the right place to define things like: + +- pane children/body content +- header actions +- tab-level trailing actions +- title/icon resolution +- pane-type-specific empty states + +These are runtime rendering concerns, not durable layout state. + +The important boundary is: + +- `PaneSurface` owns the shared pane shell +- `PaneSurface` also owns the VS Code-like hover overlay +- `PaneContent` delegates arbitrary inner content to the pane registry +- `PaneDefinition.renderPane(...)` should not receive drop-target state + +### Render Args + +The pane renderer should receive enough context to render pane-local UI without reaching into layout internals: + +```ts +type PaneRenderArgs = { + pane: PaneState; + root: PaneRootState; + groupId: string; + isActive: boolean; + isFocused: boolean; +}; + +type PaneHeaderActionsRenderArgs = { + pane: PaneState; + root: PaneRootState; + groupId: string; + isActive: boolean; +}; + +type PaneTabActionsRenderArgs = { + pane: PaneState; + root: PaneRootState; + groupId: string; + isActive: boolean; + isHovered: boolean; +}; + +type PaneRootTabRenderArgs = { + root: PaneRootState; + isActive: boolean; + dispatch: PaneDispatch; + registry: PaneRegistry; +}; + +type PaneGroupToolbarRenderArgs = { + root: PaneRootState; + group: Extract, { type: "group" }>; + activePane: PaneState | null; + dispatch: PaneDispatch; + registry: PaneRegistry; +}; + +type PaneEmptyStateRenderArgs = { + root: PaneRootState; + groupId?: string; + dispatch: PaneDispatch; + registry: PaneRegistry; +}; + +type PaneDispatch = (action: PaneLayoutAction) => void; +``` + +### How Arbitrary Pane Content Renders + +`PaneContent` should be a thin resolver. It does not know what a terminal pane or browser pane is. + +It should: + +1. read `pane.kind` +2. resolve `registry[pane.kind]` +3. call `definition.renderPane(...)` +4. render the returned React subtree inside `PaneSurface` + +Example: + +```ts +function PaneContent({ + root, + group, + pane, + registry, + isActive, + isFocused, +}: PaneTabPanelProps) { + const definition = registry[pane.kind]; + + if (!definition) { + throw new Error(`Missing pane definition for kind: ${pane.kind}`); + } + + return definition.renderPane({ + pane, + root, + groupId: group.id, + isActive, + isFocused, + }); +} +``` + +So if `pane.kind` is: + +- `terminal`, the registry returns a terminal renderer +- `browser`, the registry returns a browser renderer +- `file`, the registry returns a file renderer + +The panel shell stays generic. Only the registry-provided renderer knows what content to mount. + +### End-To-End Rendering Model + +The actual flow should be: + +1. `PaneWorkspace` + - renders the top-level root tabs + - renders the active root +2. `PaneRootView` + - recursively renders the root layout tree +3. `PaneNode` + - if `split`, render split children + - if `group`, render `PaneGroup` +4. `PaneGroup` + - renders the tab strip + - resolves the active pane + - renders `PaneSurface` +5. `PaneSurface` + - renders shared pane chrome + - renders the drag-hover overlay for that pane surface + - renders `PaneContent` +6. `PaneContent` + - looks up `registry[pane.kind]` + - mounts the pane-specific React subtree + +So yes, every pane surface can show the VS Code-style hover effect, but that effect still belongs to the shared surface component, not to the pane-specific content renderer. + +### Example Registry + +```ts +type WorkspacePaneData = + | { kind: "terminal"; sessionKey: string } + | { kind: "browser"; url: string } + | { kind: "file"; filePath: string; viewMode: "raw" | "rendered" | "diff" }; + +const paneRegistry: PaneRegistry = { + terminal: { + getTitle: (pane) => pane.data.sessionKey, + renderPane: ({ pane }) => , + }, + browser: { + getTitle: () => "Preview", + renderPane: ({ pane }) => , + }, + file: { + getTitle: (pane) => pane.data.filePath, + renderPane: ({ pane }) => ( + + ), + }, +}; +``` + +We should avoid a closed “magic” component API. The package must let apps customize: + +- tab buttons +- tab toolbars +- group headers +- root tab chrome +- drag previews +- empty states + +But core layout math and final drop semantics should stay owned by the package. + +## Persistence + +Persist the workspace model, not live runtime state. + +Persist: + +- roots +- per-root layout tree +- groups +- pane descriptors +- active ids + +Do not persist: + +- live terminal process attachment +- live browser navigation internals unless we explicitly choose session restore +- drag hover state +- DOM geometry caches + +For v2 workspaces, persistence should remain device-local and keyed by `workspaceId`. + +## Test Plan + +We should copy the **shape** of FlexLayout and Mosaic’s test suites, not their implementation. + +### 1. Core Reducer Tests + +Inspired by: + +- FlexLayout `tests/Model.test.ts` +- Mosaic `mosaicUpdates.spec.ts` +- Mosaic `mosaicUtilities.spec.ts` + +We need exhaustive state transition tests for: + +- adding panes to a group +- moving panes into another group +- splitting groups in each direction +- collapsing empty groups after close +- resizing split sizes +- moving panes between roots +- moving a pane within the same root +- preserving active pane/group/root rules +- serialization roundtrip + +These should be pure unit tests with no DOM. + +### 2. Tree Utility Tests + +Inspired by: + +- Mosaic `boundingBox.spec.ts` +- Mosaic `mosaicUtilities.spec.ts` + +We need tests for: + +- node lookup by path/id +- replacing a node in a tree +- removing a child and collapsing splits +- balancing or normalizing split trees +- geometric drop target calculation from bounding boxes + +This is where we prove the layout math works. + +### 3. Drag Interaction Tests + +New for Superset because cross-root drag/drop is a core requirement. + +We need component or integration tests for: + +- hover over another group in same root +- hover over another root tab switches active root +- hover over another group produces the correct docking preview target +- hover over group edges produces top/right/bottom/left split targets +- drop into another group moves the pane correctly +- cancel drag leaves the layout unchanged + +### 4. React Component Tests + +Renderer tests should verify: + +- active tab rendering +- close button dispatch +- split panels render recursively +- root tab activation styling +- empty group placeholder behavior +- drag enter handlers dispatch correct hover actions + +These can be component tests with mocked dispatch and small sample states. + +### 5. End-To-End Interaction Tests + +Inspired by: + +- FlexLayout `tests-playwright/view.spec.ts` + +We should add Playwright-style interaction coverage once the package stabilizes. + +Minimum scenarios: + +1. Drag tab into another group center +2. Drag tab to group left/right/top/bottom split +3. Drag tab from root A into root B +4. Hovering root B activates it before drop +5. Resize split and persist/reload layout +6. Close active tab and ensure focus falls back correctly + +The right lesson from FlexLayout is not “copy their tests”; it is: + +- keep pure model tests and UI interaction tests separate +- make drag/drop scenarios explicit and exhaustive + +## Reference Material + +Downloaded locally for inspection: + +- FlexLayout: `/tmp/FlexLayout-ref` +- react-mosaic: `/tmp/react-mosaic` + +Useful reference files: + +- FlexLayout model tests: `/tmp/FlexLayout-ref/tests/Model.test.ts` +- FlexLayout Playwright tests: `/tmp/FlexLayout-ref/tests-playwright/view.spec.ts` +- Mosaic update tests: `/tmp/react-mosaic/libs/react-mosaic-component/src/lib/util/mosaicUpdates.spec.ts` +- Mosaic utility tests: `/tmp/react-mosaic/libs/react-mosaic-component/src/lib/util/mosaicUtilities.spec.ts` +- Mosaic geometry tests: `/tmp/react-mosaic/libs/react-mosaic-component/src/lib/util/boundingBox.spec.ts` + +These should be treated as behavioral inspiration only. + +## Implementation Order + +1. Lock the core API in `@superset/pane-layout` +2. Write reducer and tree utility tests first +3. Implement root-aware reducer +4. Implement minimal React renderer with `@superset/ui` primitives +5. Add same-root drag/drop +6. Add cross-root drag activation +7. Replace the v2 pane viewer with the new package +8. Add persistence and migration helpers +9. Add end-to-end drag/resize tests + +## Recommendation + +Proceed with a clean-room implementation of: + +- a root-aware pane workspace core +- a Superset-specific React renderer +- a test suite modeled after FlexLayout’s model/e2e split and Mosaic’s tree utility coverage + +Do **not** continue iterating on the current spike package until the API and test surface above are accepted. diff --git a/apps/desktop/plans/20260322-2335-pane-layout-model-comparison.md b/apps/desktop/plans/20260322-2335-pane-layout-model-comparison.md new file mode 100644 index 00000000000..6ea5404a5f2 --- /dev/null +++ b/apps/desktop/plans/20260322-2335-pane-layout-model-comparison.md @@ -0,0 +1,395 @@ +# Pane Layout Model Comparison + +## Goal + +Compare the data model and API shape of: + +1. `react-mosaic` +2. `FlexLayout` +3. Our proposed Superset pane-layout implementation + +This doc is intentionally focused on the core model and API, not styling. + +Reference files used: + +- React Mosaic + - `/tmp/react-mosaic/libs/react-mosaic-component/src/lib/types.ts` + - `/tmp/react-mosaic/libs/react-mosaic-component/src/lib/Mosaic.tsx` + - `/tmp/react-mosaic/libs/react-mosaic-component/src/lib/util/mosaicUpdates.ts` + - `/tmp/react-mosaic/libs/react-mosaic-component/src/lib/util/mosaicUtilities.ts` +- FlexLayout + - `/tmp/FlexLayout-ref/src/model/IJsonModel.ts` + - `/tmp/FlexLayout-ref/src/model/Model.ts` + - `/tmp/FlexLayout-ref/src/model/Actions.ts` + - `/tmp/FlexLayout-ref/src/model/Node.ts` + - `/tmp/FlexLayout-ref/src/model/TabSetNode.ts` + +--- + +## React Mosaic + +### Data Model + +React Mosaic is fundamentally a tree-first layout model. + +Its current core type is: + +```ts +type MosaicNode = + | MosaicSplitNode + | MosaicTabsNode + | T; +``` + +Where: + +- split nodes are n-ary and store: + - `direction` + - `children` + - optional `splitPercentages` +- tab containers store: + - `tabs: T[]` + - `activeTabIndex` +- leaves are just keys of type `T` + +Important characteristics: + +- The tree is the source of truth. +- Leaf content is not embedded in the tree. The tree only stores keys. +- Tree updates are path-based with `MosaicPath = number[]`. +- Update operations are expressed as tree mutations rather than a semantic window/group model. + +### API Shape + +The public API is renderer-first: + +- `renderTile` +- `renderTabToolbar` +- `renderTabButton` +- `onChange` +- `onRelease` + +The action surface is effectively: + +- tree update specs +- remove/hide/show/expand operations +- renderer callbacks + +There is no first-class concept of: + +- multiple roots +- persisted workspace session +- drag session object +- pane descriptors with metadata +- explicit group ids + +### Strengths + +- Very simple tree contract +- Strong utility layer for tree updates +- Paths and split geometry are well-defined +- Tabs are integrated into the tree rather than bolted on + +### Limits For Superset + +- No multi-root session model +- Path-based identity is not enough for our cross-root drag requirement +- Leaf nodes are just keys, so the app has to own all pane metadata elsewhere +- API is oriented around a single mosaic instance, not a whole workspace session + +--- + +## FlexLayout + +### Data Model + +FlexLayout also uses a nested persisted model, but it is richer and more IDE-oriented. + +Its persisted JSON root is: + +```ts +type IJsonModel = { + global?: IGlobalAttributes; + borders?: IJsonBorderNode[]; + layout: IJsonRowNode; + popouts?: Record; +}; +``` + +The main nested model is: + +- `row` +- `tabset` +- `tab` + +Important characteristics: + +- Persisted state is nested JSON, not normalized maps. +- The model includes optional borders and popouts. +- Tabs store config data inside tab nodes. +- Active/maximized tabsets can be represented in persisted JSON. + +### Runtime Model + +FlexLayout’s runtime model is more than the JSON suggests. + +Internally it keeps: + +- an `idMap: Map` +- a `windows: Map` +- a `rootWindow` +- active/maximized tabset tracking per window + +So the actual architecture is: + +- nested tree for persistence +- indexed runtime model for operations + +### API Shape + +The public action API is semantic and id-based: + +- `addNode` +- `moveNode` +- `deleteTab` +- `deleteTabset` +- `selectTab` +- `setActiveTabset` +- `adjustWeights` +- `createWindow` +- `closeWindow` +- `popoutTab` +- `popoutTabset` + +This is much closer to a full workbench model than Mosaic. + +### Strengths + +- Good separation between durable JSON and runtime indexes +- Id-based actions are better than pure path-based tree updates +- Explicit support for multiple windows/popouts +- Explicit docking targets and richer drag/drop semantics + +### Limits For Superset + +- The model is coupled to FlexLayout-specific concepts: + - borders + - popouts + - tabsets as the dominant abstraction +- The runtime model is object-heavy and class-based +- The API assumes FlexLayout’s own renderer and interaction rules +- The window model is oriented around browser popouts rather than our app-managed workbench roots + +--- + +## Our Implementation + +### Public Data Model + +Our public persisted model should be tree-first, but one level higher than either library because our product requirement starts with multiple roots rendered as top-level tabs. + +```ts +type PersistedPaneWorkspaceState = { + version: 1; + roots: PaneRootState[]; + activeRootId: string | null; +}; + +type PaneRootState = { + id: string; + root: PaneLayoutNode; + activeGroupId: string | null; +}; + +type PaneLayoutNode = + | { + type: "group"; + id: string; + activePaneId: string | null; + panes: PaneState[]; + } + | { + type: "split"; + id: string; + direction: "horizontal" | "vertical"; + sizes: number[]; + children: PaneLayoutNode[]; + }; + +type PaneState = { + id: string; + kind: string; + titleOverride?: string; + pinned?: boolean; + data: TPaneData; +}; +``` + +There is no persisted or store-owned drag session in the core model. + +### Public API Shape + +The package should expose: + +- a platform-agnostic `core` +- a React `renderer` + +Core API: + +- types +- reducer actions +- serialization helpers +- geometry/drop-target helpers +- runtime index builders + +React API: + +- workspace renderer +- per-root renderer +- customization hooks for pane content, tab chrome, and group/root chrome + +### Runtime Model + +Like FlexLayout, we should maintain derived runtime indexes: + +```ts +type RuntimeIndexes = { + groupPathById: Map; + paneLocationById: Map; +}; +``` + +These are an implementation detail, not the persisted contract. + +### Drag Boundary + +Because roots are top-level tabs inside one React tree, transient drag state can live in the renderer drag layer rather than the core store. + +The core still needs: + +- semantic `DropTarget` types +- geometry helpers for resolving drop targets +- final mutations like `movePane(...)` and `splitGroup(...)` + +The renderer owns: + +- drag monitor state +- overlay previews +- root-tab hover activation before drop + +### Why This Differs From React Mosaic + +#### 1. We have a workspace-level root model + +Defense: + +- Mosaic models one layout tree, not a multi-root session. +- Our product requirement starts with multiple roots, so `PaneWorkspaceState` must exist as a first-class layer. +- Putting roots outside the model would make persistence, focus, and cross-root moves much messier. + +#### 2. We use stable ids for actions, not only paths + +Defense: + +- Mosaic’s path-based updates are elegant for one tree, but paths are too volatile for multi-step drop operations and cross-root moves. +- Stable ids let reducer actions remain valid even if normalization or collapse changes the path shape. +- We can still derive paths internally when tree surgery needs them. + +#### 3. Group nodes inline their panes + +Defense: + +- This keeps the public model readable and self-contained. +- It avoids making the persisted contract overly normalized for the sake of reducer convenience. +- It is closer to the actual user mental model: “this group contains these tabs.” + +#### 4. We store pane descriptors, not just leaf keys + +Defense: + +- Our pane engine needs to persist pane identity and restore metadata directly. +- Using only keys would force every consumer to maintain a second authoritative pane registry outside the layout. +- That is acceptable for a generic tile library, but too indirect for a workbench engine. + +#### 5. We use `titleOverride` rather than making a computed `title` mandatory + +Defense: + +- A mandatory stored title duplicates pane metadata and creates stale-title risk. +- Most pane titles should be derived from pane data by the adapter or renderer layer. +- We only need durable title state when the user explicitly renames or overrides the default title. + +### Why This Differs From FlexLayout + +#### 1. We have a workspace/session root above roots + +Defense: + +- FlexLayout’s window story is centered around popouts attached to a single model. +- Our product wants explicit app-managed roots as part of the workbench session. +- A workspace-level root makes root order, active root, and cross-root moves explicit. + +#### 2. We do not expose borders or popouts in the v1 contract + +Defense: + +- They are not part of our required product scope. +- Including them early would distort the core model around features we do not plan to ship first. +- This keeps the model focused on editor-group style panes. + +#### 3. We do not make tabsets the dominant top-level concept + +Defense: + +- In our model, the more important abstraction is the `group` as a workbench leaf. +- A group happens to render tabs, but it is semantically a split leaf in a rooted workbench. +- This better matches the distinction we need between: + - workspace + - root + - group + - pane + +#### 4. We use plain reducer/state objects, not class-heavy node models + +Defense: + +- We need the engine to be easy to run in web and desktop, easy to test, and easy to serialize. +- Pure data + reducers is a better fit for React app state, deterministic tests, and local persistence. +- We can still adopt FlexLayout’s runtime lesson and maintain derived indexes without copying its class hierarchy. + +#### 5. We do not put drag session state in the core store + +Defense: + +- Our roots are tabs in one React tree, so `react-dnd` or equivalent can own transient hover state cleanly. +- Keeping hover state out of the core model avoids overfitting the engine to one drag implementation. +- The core still owns semantic `DropTarget` types and final mutations, which keeps the actual drop behavior deterministic and testable. + +#### 6. We do not persist `rootOrder` separately from `roots` + +Defense: + +- If root order matters, an ordered `roots[]` array is enough. +- Keeping both `roots` and `rootOrder` in the durable schema creates two sources of truth. +- We can still derive a `Map` at runtime for efficient lookup. + +### Summary + +React Mosaic is the simpler tree-first reference. + +FlexLayout is the richer workbench reference, and the most important lesson from it is: + +- keep the durable model nested +- keep runtime indexes explicit +- keep action semantics id-based + +Our implementation should combine those lessons with our own product requirements: + +- top-level multi-root session +- tree-first persisted roots +- inline groups and panes in the public model +- derived runtime indexes +- renderer-owned transient drag state with core-owned drop semantics +- platform-agnostic core plus React renderer + +That is the model I would treat as “correct” for Superset, even where it diverges from both reference libraries. diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts index cd88ec66649..8a5d39ef069 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts @@ -78,7 +78,7 @@ export function useDashboardSidebarData() { const { data: sidebarWorkspaces = [] } = useLiveQuery( (q) => q - .from({ sidebarWorkspaces: collections.v2SidebarWorkspaces }) + .from({ sidebarWorkspaces: collections.v2WorkspaceLocalState }) .innerJoin( { workspaces: collections.v2Workspaces }, ({ sidebarWorkspaces, workspaces }) => @@ -88,10 +88,13 @@ export function useDashboardSidebarData() { { devices: collections.v2Devices }, ({ workspaces, devices }) => eq(workspaces.deviceId, devices.id), ) - .orderBy(({ sidebarWorkspaces }) => sidebarWorkspaces.tabOrder, "asc") + .orderBy( + ({ sidebarWorkspaces }) => sidebarWorkspaces.sidebarState.tabOrder, + "asc", + ) .select(({ sidebarWorkspaces, workspaces, devices }) => ({ id: workspaces.id, - projectId: sidebarWorkspaces.projectId, + projectId: sidebarWorkspaces.sidebarState.projectId, deviceId: workspaces.deviceId, deviceType: devices?.type ?? null, deviceClientId: devices?.clientId ?? null, @@ -99,8 +102,8 @@ export function useDashboardSidebarData() { branch: workspaces.branch, createdAt: workspaces.createdAt, updatedAt: workspaces.updatedAt, - tabOrder: sidebarWorkspaces.tabOrder, - sectionId: sidebarWorkspaces.sectionId, + tabOrder: sidebarWorkspaces.sidebarState.tabOrder, + sectionId: sidebarWorkspaces.sidebarState.sectionId, })), [collections], ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx index 98ee6a4c365..4be47ca9183 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -96,7 +96,7 @@ function DashboardLayout() { return (
-
+
{isWorkspaceSidebarOpen && ( )} - +
+ +
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/PaneViewer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/PaneViewer.tsx new file mode 100644 index 00000000000..ba1d4d82cca --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/PaneViewer.tsx @@ -0,0 +1,329 @@ +import { + createPaneRoot, + type PaneRegistry, + PaneWorkspace, +} from "@superset/pane-layout"; +import { + DropdownMenuCheckboxItem, + DropdownMenuItem, + DropdownMenuSeparator, +} from "@superset/ui/dropdown-menu"; +import { useNavigate } from "@tanstack/react-router"; +import { FileCode2, Globe, MessageSquare, TerminalSquare } from "lucide-react"; +import { useCallback, useMemo } from "react"; +import { BsTerminalPlus } from "react-icons/bs"; +import { TbMessageCirclePlus, TbWorld } from "react-icons/tb"; +import { HotkeyMenuShortcut } from "renderer/components/HotkeyMenuShortcut"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { WorkspaceChat } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat"; +import { WorkspaceFilePreview } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/WorkspaceFilePreview"; +import { WorkspaceTerminal } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceTerminal"; +import { + CommandPalette, + useCommandPalette, +} from "renderer/screens/main/components/CommandPalette"; +import { PresetsBar } from "renderer/screens/main/components/WorkspaceView/ContentView/components/PresetsBar"; +import { useAppHotkey } from "renderer/stores/hotkeys"; +import { DEFAULT_SHOW_PRESETS_BAR } from "shared/constants"; +import { PaneViewerEmptyState } from "./components/PaneViewerEmptyState"; +import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; +import { + type BrowserPaneData, + type ChatPaneData, + createBrowserPane, + createChatPane, + createFilePane, + createTerminalPane, + type DevtoolsPaneData, + type FilePaneData, + type PaneViewerData, + type TerminalPaneData, +} from "./pane-viewer.model"; + +interface PaneViewerProps { + projectId: string; + workspaceId: string; + workspaceName: string; +} + +function getFileTitle(filePath: string): string { + return filePath.split("/").pop() ?? filePath; +} + +export function PaneViewer({ + projectId, + workspaceId, + workspaceName, +}: PaneViewerProps) { + const navigate = useNavigate(); + const { store } = useV2WorkspacePaneLayout({ + projectId, + workspaceId, + }); + const utils = electronTrpc.useUtils(); + const { data: showPresetsBar } = + electronTrpc.settings.getShowPresetsBar.useQuery(); + const setShowPresetsBar = electronTrpc.settings.setShowPresetsBar.useMutation( + { + onMutate: async ({ enabled }) => { + await utils.settings.getShowPresetsBar.cancel(); + const previous = utils.settings.getShowPresetsBar.getData(); + utils.settings.getShowPresetsBar.setData(undefined, enabled); + return { previous }; + }, + onError: (_error, _variables, context) => { + if (context?.previous !== undefined) { + utils.settings.getShowPresetsBar.setData(undefined, context.previous); + } + }, + onSettled: () => { + utils.settings.getShowPresetsBar.invalidate(); + }, + }, + ); + + const openFilePane = useCallback( + (filePath: string) => { + const pane = createFilePane({ + title: getFileTitle(filePath), + filePath, + mode: "editor", + hasChanges: false, + }); + const activePane = store.getState().getActivePane(); + + if (activePane) { + store.getState().addPaneToGroup({ + rootId: activePane.rootId, + groupId: activePane.groupId, + pane, + replaceUnpinned: true, + select: true, + }); + return; + } + + store.getState().addRoot( + createPaneRoot({ + titleOverride: "Files", + panes: [pane], + }), + ); + }, + [store], + ); + + const addTerminalRoot = useCallback(() => { + store.getState().addRoot( + createPaneRoot({ + titleOverride: "Terminal", + panes: [ + createTerminalPane({ + title: "Terminal", + sessionKey: `${workspaceId}:${crypto.randomUUID()}`, + cwd: `/workspace/${workspaceName}`, + launchMode: "workspace-shell", + }), + ], + }), + ); + }, [store, workspaceId, workspaceName]); + + const addChatRoot = useCallback(() => { + store.getState().addRoot( + createPaneRoot({ + titleOverride: "Chat", + panes: [ + createChatPane({ + title: "Chat", + sessionId: null, + }), + ], + }), + ); + }, [store]); + + const addBrowserRoot = useCallback(() => { + store.getState().addRoot( + createPaneRoot({ + titleOverride: "Browser", + panes: [ + createBrowserPane({ + title: "Browser", + url: "http://localhost:3000", + mode: "preview", + }), + ], + }), + ); + }, [store]); + + const commandPalette = useCommandPalette({ + workspaceId, + navigate, + onSelectFile: ({ close, filePath, targetWorkspaceId }) => { + close(); + + if (targetWorkspaceId !== workspaceId) { + void navigate({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId: targetWorkspaceId }, + }); + return; + } + + openFilePane(filePath); + }, + }); + + const handleQuickOpen = useCallback(() => { + commandPalette.toggle(); + }, [commandPalette]); + const setPaneData = store.getState().setPaneData; + + const paneRegistry = useMemo>( + () => ({ + file: { + getIcon: () => , + renderPane: ({ pane }) => { + const data = pane.data as FilePaneData; + + return ( + + ); + }, + }, + terminal: { + getIcon: () => , + renderPane: ({ pane }) => { + const _data = pane.data as TerminalPaneData; + return ; + }, + }, + browser: { + getIcon: () => , + renderPane: ({ pane }) => { + const data = pane.data as BrowserPaneData; + + return ( +