diff --git a/src/App.tsx b/src/App.tsx index 0707930..e82de10 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -281,6 +281,21 @@ function AppShell() { setOpenRequestId((id) => id + 1); }, []); + const handleNewFile = useCallback(async () => { + const provider = resolveProvider(selectedProviderId); + if (!provider) return; + try { + const entry = await provider.createFile(); + handleOpenEntry({ + providerId: provider.id, + entryId: entry.id, + title: entry.name, + }); + } catch (e) { + console.error('[workspace] failed to create new file', e); + } + }, [resolveProvider, selectedProviderId, handleOpenEntry]); + const handleOpenVault = useCallback( async ( vaultPath: string, @@ -375,10 +390,13 @@ function AppShell() { element={ p.id)} + selectedVaultId={selectedProviderId} openRequestId={openRequestId} openRequest={openRequest} resolveProvider={resolveProvider} tilingHandleRef={tilingHandleRef} + onNewFile={handleNewFile} /> } /> diff --git a/src/tiling/model.test.ts b/src/tiling/model.test.ts index ee4803d..d93a24c 100644 --- a/src/tiling/model.test.ts +++ b/src/tiling/model.test.ts @@ -7,6 +7,7 @@ import { listTileIds, MIN_TILE_HEIGHT_PX, MIN_TILE_WIDTH_PX, + planTileRemoval, removeLeaf, splitLeaf, updateSplitRatio, @@ -191,6 +192,38 @@ describe('listTileIds', () => { }); }); +describe('planTileRemoval', () => { + it('reseeds when only one tile remains', () => { + const tree = createInitialTree('only'); + expect(planTileRemoval(tree, 'only', 'only')).toEqual({ kind: 'reseed' }); + }); + + it('collapses the split and keeps focus when a non-active tile closes', () => { + const tree = splitRow('root', 0.5, tile('a'), tile('b')); + const plan = planTileRemoval(tree, 'b', 'a'); + expect(plan).toEqual({ + kind: 'collapse', + nextTree: tile('a'), + nextActiveTileId: 'a', + }); + }); + + it('moves focus to the first survivor when the active tile closes', () => { + const tree: TilingNode = splitRow( + 'root', + 0.5, + tile('a'), + splitRow('inner', 0.5, tile('b'), tile('c')), + ); + const plan = planTileRemoval(tree, 'a', 'a'); + expect(plan.kind).toBe('collapse'); + if (plan.kind === 'collapse') { + expect(listTileIds(plan.nextTree)).toEqual(['b', 'c']); + expect(plan.nextActiveTileId).toBe('b'); + } + }); +}); + describe('clampRatioForAxis', () => { it('returns the input ratio when axis size is non-positive', () => { expect(clampRatioForAxis(0.1, 'row', 0)).toBe(0.1); diff --git a/src/tiling/model.ts b/src/tiling/model.ts index 4280557..190099c 100644 --- a/src/tiling/model.ts +++ b/src/tiling/model.ts @@ -177,3 +177,32 @@ export function listTileIds(node: TilingNode): string[] { if (node.kind === 'tile') return [node.id]; return [...listTileIds(node.first), ...listTileIds(node.second)]; } + +/** + * What to do with the split tree when a tile loses its last tab. + * - `reseed`: it was the only tile — keep it and refill it with an empty tab. + * - `collapse`: remove the tile (sibling expands) and report the next focus. + */ +export type TileClosePlan = + | { kind: 'reseed' } + | { kind: 'collapse'; nextTree: TilingNode; nextActiveTileId: string }; + +/** + * Decide how to update the tree when `tileId`'s last tab closes. Pure so the + * component can stay a thin applier. When the closed tile was active, focus + * moves to the first surviving tile (left-to-right). + */ +export function planTileRemoval( + tree: TilingNode, + tileId: string, + activeTileId: string, +): TileClosePlan { + if (listTileIds(tree).length <= 1) { + return { kind: 'reseed' }; + } + const nextTree = removeLeaf(tree, tileId); + const survivors = listTileIds(nextTree); + const nextActiveTileId = + activeTileId === tileId ? (survivors[0] ?? tileId) : activeTileId; + return { kind: 'collapse', nextTree, nextActiveTileId }; +} diff --git a/src/workspaces/barSlot.tsx b/src/workspaces/barSlot.tsx index cf97c3b..9db83cb 100644 --- a/src/workspaces/barSlot.tsx +++ b/src/workspaces/barSlot.tsx @@ -10,8 +10,18 @@ const BarTargetContext = createContext(null); export const WorkspaceBarTargetProvider = BarTargetContext.Provider; +/** + * Whether the surrounding workspace is the active one. Inactive workspaces stay + * mounted (to preserve their layout/tabs) but must not paint the shared bottom + * bar, so their {@link WorkspaceBarSlot} renders nothing. + */ +const BarActiveContext = createContext(true); + +export const WorkspaceActiveProvider = BarActiveContext.Provider; + export function WorkspaceBarSlot({ children }: { children: ReactNode }) { const target = useContext(BarTargetContext); - if (!target) return null; + const active = useContext(BarActiveContext); + if (!target || !active) return null; return createPortal(children, target); } diff --git a/src/workspaces/tiling/TileStartView.tsx b/src/workspaces/tiling/TileStartView.tsx new file mode 100644 index 0000000..30977b3 --- /dev/null +++ b/src/workspaces/tiling/TileStartView.tsx @@ -0,0 +1,37 @@ +import { NewDocumentIcon } from '../../icons'; + +type TileStartViewProps = { + className?: string; + /** Create a new file and open it in this tile. Absent when no provider is selected. */ + onNewFile?: () => void; +}; + +/** + * Default content for a tile whose active tab holds no document. Shows a short + * list of quick actions rather than an "Untitled" tab. For now the only action + * is opening a new file. + */ +export function TileStartView({ className, onNewFile }: TileStartViewProps) { + return ( +
+
+ Quick actions +
+
    +
  • + +
  • +
+
+ ); +} diff --git a/src/workspaces/tiling/TileTabStrip.tsx b/src/workspaces/tiling/TileTabStrip.tsx new file mode 100644 index 0000000..8d7d8e5 --- /dev/null +++ b/src/workspaces/tiling/TileTabStrip.tsx @@ -0,0 +1,97 @@ +import type { MouseEvent } from 'react'; + +import { PlusIcon, XIcon } from '../../icons'; +import type { DocumentSlotState } from '../../documents/useDocumentSlot'; +import type { TileTab } from './tabsModel'; + +function tabTitle(slot: DocumentSlotState): string { + // Empty tabs are never rendered as chips (see below), so this only ever + // formats a tab that holds a document request. + return slot.kind === 'empty' ? '' : slot.request.title; +} + +type TileTabStripProps = { + tabs: readonly TileTab[]; + activeTabId: string | null; + onSelect: (tabId: string) => void; + onClose: (tabId: string) => void; + onNew: () => void; +}; + +/** + * Horizontal tab strip for a single tile. Renders one button per open tab + * (title + close affordance) and a trailing "new tab" button. Selection and + * close are delegated to the owning tile; this component is presentational. + */ +export function TileTabStrip({ + tabs, + activeTabId, + onSelect, + onClose, + onNew, +}: TileTabStripProps) { + const handleAuxClose = (event: MouseEvent, tabId: string) => { + // Middle-click closes the tab. + if (event.button === 1) { + event.preventDefault(); + onClose(tabId); + } + }; + + // Empty tabs are the tile's start page, not their own chip — only documents + // get a tab. + const documentTabs = tabs.filter((tab) => tab.slot.kind !== 'empty'); + + return ( +
+ {documentTabs.map((tab) => { + const isActive = tab.tabId === activeTabId; + const title = tabTitle(tab.slot); + return ( +
handleAuxClose(e, tab.tabId)} + onClick={() => onSelect(tab.tabId)} + className={`group flex shrink-0 cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs ${ + isActive + ? 'bg-accent/15 text-foreground' + : 'text-foreground/60 hover:bg-border/30' + }`} + > + {title} + +
+ ); + })} + +
+ ); +} diff --git a/src/workspaces/tiling/tabsModel.test.ts b/src/workspaces/tiling/tabsModel.test.ts index 24cec22..9c31f11 100644 --- a/src/workspaces/tiling/tabsModel.test.ts +++ b/src/workspaces/tiling/tabsModel.test.ts @@ -6,6 +6,7 @@ import { closeTab, createTile, ensureTile, + findEmptyTab, findTabForRequest, getActiveSlot, getActiveTabId, @@ -145,6 +146,22 @@ describe('findTabForRequest', () => { }); }); +describe('findEmptyTab', () => { + it('finds an empty tab when one exists', () => { + const map = addTab( + tileWithDocs('tile-1', ['a'], 'a'), + 'tile-1', + 'tab-blank', + ); + expect(findEmptyTab(map, 'tile-1')).toBe('tab-blank'); + }); + + it('returns null when every tab holds a document', () => { + const map = tileWithDocs('tile-1', ['a', 'b'], 'a'); + expect(findEmptyTab(map, 'tile-1')).toBeNull(); + }); +}); + describe('closeTab', () => { it('removes a non-active tab without changing the active tab', () => { const map = tileWithDocs('tile-1', ['a', 'b', 'c'], 'a'); diff --git a/src/workspaces/tiling/tabsModel.ts b/src/workspaces/tiling/tabsModel.ts index a5d9bb6..0786c5c 100644 --- a/src/workspaces/tiling/tabsModel.ts +++ b/src/workspaces/tiling/tabsModel.ts @@ -148,6 +148,13 @@ export function findTabForRequest( return null; } +/** The id of an empty (document-less) tab in the tile, or null. */ +export function findEmptyTab(map: TileTabsMap, tileId: string): string | null { + const tile = map[tileId]; + if (!tile) return null; + return tile.tabs.find((t) => t.slot.kind === 'empty')?.tabId ?? null; +} + export type CloseTabResult = { map: TileTabsMap; /** Tab ids whose document handles the caller must release. */ diff --git a/src/workspaces/tiling/tiling.tsx b/src/workspaces/tiling/tiling.tsx index 71f01ce..41e2de4 100644 --- a/src/workspaces/tiling/tiling.tsx +++ b/src/workspaces/tiling/tiling.tsx @@ -6,6 +6,7 @@ import { clampRatioForAxis, createInitialTree, DEFAULT_SPLIT_RATIO, + planTileRemoval, splitLeaf, SplitNode, SplitOrientation, @@ -16,6 +17,9 @@ import { useResizeObserver } from '../../tiling/useResizeObserver'; import { WorkspaceBarSlot } from '../barSlot'; import type { WorkspaceProps } from '../workspace'; import { useTilingDocumentSlots } from './useTilingDocumentSlots'; +import { TileTabStrip } from './TileTabStrip'; +import { TileStartView } from './TileStartView'; +import type { TileTab } from './tabsModel'; import type { TilingHandle } from '../../actions'; type SplitterProps = { @@ -104,25 +108,29 @@ function SplitterLayout({ node, onRatioChange, renderNode }: SplitterProps) { ); } -function slotLabel(state: DocumentSlotState, fallback: string): string { - if ( - state.kind === 'open' || - state.kind === 'loading' || - state.kind === 'error' - ) { - return state.request.title; - } - return fallback; -} - type TilingTileProps = { - tileId: string; isActive: boolean; slot: DocumentSlotState; + tabs: readonly TileTab[]; + activeTabId: string | null; onActivate: () => void; + onSelectTab: (tabId: string) => void; + onCloseTab: (tabId: string) => void; + onNewTab: () => void; + onNewFile?: () => void; }; -function TilingTile({ tileId, isActive, slot, onActivate }: TilingTileProps) { +function TilingTile({ + isActive, + slot, + tabs, + activeTabId, + onActivate, + onSelectTab, + onCloseTab, + onNewTab, + onNewFile, +}: TilingTileProps) { return (
-
- {slotLabel(slot, tileId)} -
- + + {slot.kind === 'empty' ? ( + + ) : ( + + )}
); } export function TilingWorkspace({ + active, openRequestId, openRequest, resolveProvider, tilingHandleRef, + onNewFile, }: WorkspaceProps) { const nextIdRef = useRef(2); const makeId = (prefix: string) => `${prefix}-${nextIdRef.current++}`; @@ -156,13 +174,38 @@ export function TilingWorkspace({ ); const [activeTileId, setActiveTileId] = useState('tile-1'); - const { getSlot, ensureTile } = useTilingDocumentSlots({ + const { + getSlot, + getTabs, + getActiveTabId, + ensureTile, + setActiveTab, + newTab, + closeTab, + } = useTilingDocumentSlots({ + active, openRequestId, openRequest, resolveProvider, activeTileId, }); + /** + * Close a tab. When it was the tile's last tab, collapse the split (or, for + * the sole remaining tile, reseed it as an empty tile) and move focus. + */ + const handleCloseTab = (tileId: string, tabId: string) => { + const { tileEmptied } = closeTab(tileId, tabId); + if (!tileEmptied) return; + const plan = planTileRemoval(tree, tileId, activeTileId); + if (plan.kind === 'reseed') { + ensureTile(tileId); + return; + } + setTree(plan.nextTree); + setActiveTileId(plan.nextActiveTileId); + }; + const applySplit = (orientation: SplitOrientation) => { const splitId = makeId('split'); const tileId = makeId('tile'); @@ -182,7 +225,9 @@ export function TilingWorkspace({ applySplitRef.current = applySplit; useEffect(() => { - if (!tilingHandleRef) return; + // Only the shown tiling workspace exposes its split handle, so split + // commands never act on a hidden workspace. + if (!tilingHandleRef || !active) return; const handle: TilingHandle = { splitRight: () => applySplitRef.current('row'), splitDown: () => applySplitRef.current('column'), @@ -191,17 +236,33 @@ export function TilingWorkspace({ return () => { tilingHandleRef.current = null; }; - }, [tilingHandleRef]); + }, [tilingHandleRef, active]); const renderNode = (node: TilingNode): ReactNode => { if (node.kind === 'tile') { + const tileId = node.id; return ( setActiveTileId(node.id)} + key={tileId} + isActive={tileId === activeTileId} + slot={getSlot(tileId)} + tabs={getTabs(tileId)} + activeTabId={getActiveTabId(tileId)} + onActivate={() => setActiveTileId(tileId)} + onSelectTab={(tabId) => setActiveTab(tileId, tabId)} + onCloseTab={(tabId) => handleCloseTab(tileId, tabId)} + onNewTab={() => { + setActiveTileId(tileId); + newTab(tileId); + }} + onNewFile={ + onNewFile + ? () => { + setActiveTileId(tileId); + onNewFile(); + } + : undefined + } /> ); } diff --git a/src/workspaces/tiling/useTilingDocumentSlots.ts b/src/workspaces/tiling/useTilingDocumentSlots.ts index b3c2e3b..13d3d59 100644 --- a/src/workspaces/tiling/useTilingDocumentSlots.ts +++ b/src/workspaces/tiling/useTilingDocumentSlots.ts @@ -11,6 +11,8 @@ const INITIAL_TILE_ID = 'tile-1'; const INITIAL_TAB_ID = 'tab-1'; export type UseTilingDocumentSlotsInput = { + /** When false (workspace not shown), open intents are ignored. */ + active: boolean; openRequestId: number; openRequest: OpenDocRequest | null; resolveProvider: WorkspaceProps['resolveProvider']; @@ -27,6 +29,7 @@ export type UseTilingDocumentSlotsInput = { * `DocumentHandle`s (tracked per tab id) on close and unmount. */ export function useTilingDocumentSlots({ + active, openRequestId, openRequest, resolveProvider, @@ -67,7 +70,9 @@ export function useTilingDocumentSlots({ useEffect(() => { if (openRequestId === lastSeenIdRef.current) return; lastSeenIdRef.current = openRequestId; - if (!openRequest) return; + // Advance last-seen even when inactive so a switch-back never replays an + // open that landed in another workspace. + if (!active || !openRequest) return; const request = openRequest; const tileId = activeTileId; @@ -102,7 +107,14 @@ export function useTilingDocumentSlots({ }, isStaleHandle: (handle) => handleRefs.current.get(tabId) !== handle, }); - }, [openRequestId, openRequest, activeTileId, resolveProvider, makeTabId]); + }, [ + active, + openRequestId, + openRequest, + activeTileId, + resolveProvider, + makeTabId, + ]); const ensureTile = useCallback( (tileId: string) => { @@ -135,9 +147,20 @@ export function useTilingDocumentSlots({ setTileTabs((prev) => tabsModel.setActiveTab(prev, tileId, tabId)); }, []); - /** Open a new empty tab in a tile and activate it. Returns the new tab id. */ + /** + * Focus the tile's empty "start" tab, creating one only if none exists. + * Empty tabs are the start page (not chips), so we never pile them up. + * Returns the focused tab id. + */ const newTab = useCallback( (tileId: string): string => { + const existingEmpty = tabsModel.findEmptyTab(tileTabsRef.current, tileId); + if (existingEmpty) { + setTileTabs((prev) => + tabsModel.setActiveTab(prev, tileId, existingEmpty), + ); + return existingEmpty; + } const tabId = makeTabId(); setTileTabs((prev) => tabsModel.addTab(prev, tileId, tabId)); return tabId; diff --git a/src/workspaces/workspace.tsx b/src/workspaces/workspace.tsx index 1706c48..c08a142 100644 --- a/src/workspaces/workspace.tsx +++ b/src/workspaces/workspace.tsx @@ -2,6 +2,7 @@ import type { ComponentType, MutableRefObject } from 'react'; import type { FileSystemProvider } from '../filesystem/types'; import { TilingWorkspace } from './tiling/tiling'; import { ZenWorkspace } from './zen/zen'; +import { WorkspaceActiveProvider } from './barSlot'; import type { TilingHandle } from '../actions'; export type WorkspaceKind = 'tiling' | 'zen'; @@ -35,6 +36,12 @@ export type OpenDocRequest = { }; export type WorkspaceProps = { + /** + * Whether this workspace is the one currently shown. Inactive workspaces stay + * mounted so their tile layout and tabs survive a switch, but they ignore + * open intents and do not paint the bottom bar. + */ + active: boolean; /** * Monotonically bumped each time the shell issues a new open intent. * Workspaces detect "a new open arrived" by comparing against their @@ -52,6 +59,12 @@ export type WorkspaceProps = { * Only meaningful for the tiling workspace; other kinds ignore it. */ tilingHandleRef?: MutableRefObject; + /** + * Create a new file in the selected source and open it in the active tile. + * Surfaced by a tile's empty-state "Open a new file" action. Absent when no + * source is selected. + */ + onNewFile?: () => void; }; export type WorkspaceComponent = ComponentType; @@ -96,32 +109,75 @@ export function isWorkspaceKind(value: unknown): value is WorkspaceKind { type WorkspaceHostProps = { kind: WorkspaceKind; + /** Provider ids of the mounted vaults, one workspace instance per vault. */ + vaultIds: readonly string[]; + /** The currently selected vault's provider id. */ + selectedVaultId: string; openRequestId: number; openRequest: OpenDocRequest | null; resolveProvider: (providerId: string) => FileSystemProvider | undefined; tilingHandleRef?: MutableRefObject; + onNewFile?: () => void; }; /** - * Mounts the active workspace. The `key={kind}` remount on switch is - * intentional: each workspace owns its own state, and resetting on switch - * keeps the abstraction boundary clean (no cross-kind state to reason about). + * Mounts one workspace instance per (kind × vault) and shows only the one + * matching the current kind and selected vault. Keeping every instance mounted + * preserves each vault's tile layout and tabs (and its open documents), so + * switching vaults — or modes — restores that vault's setup exactly. Inactive + * instances are hidden, ignore open intents (`active=false`), and suppress + * their bottom-bar portal. */ export function WorkspaceHost({ kind, + vaultIds, + selectedVaultId, openRequestId, openRequest, resolveProvider, tilingHandleRef, + onNewFile, }: WorkspaceHostProps) { - const { Component } = getWorkspaceDescriptor(kind); + // Always render at least one slot — even before any vault is mounted — so the + // empty start page is visible. Guarantee exactly one active vault key. + const vaultKeys = vaultIds.length > 0 ? vaultIds : ['']; + const activeVaultKey = vaultKeys.includes(selectedVaultId) + ? selectedVaultId + : vaultKeys[0]; + return ( - + <> + {vaultKeys.flatMap((vaultKey) => + WORKSPACE_DESCRIPTORS.map(({ kind: descriptorKind, Component }) => { + const isActive = + descriptorKind === kind && vaultKey === activeVaultKey; + return ( +
+ + + +
+ ); + }), + )} + ); } diff --git a/src/workspaces/zen/zen.tsx b/src/workspaces/zen/zen.tsx index 166a5bc..d85cc4b 100644 --- a/src/workspaces/zen/zen.tsx +++ b/src/workspaces/zen/zen.tsx @@ -5,6 +5,7 @@ import { WorkspaceBarSlot } from '../barSlot'; import type { WorkspaceProps } from '../workspace'; export function ZenWorkspace({ + active, openRequestId, openRequest, resolveProvider, @@ -13,6 +14,7 @@ export function ZenWorkspace({ openRequestId, openRequest, resolveProvider, + accepts: active, }); const handle = state.kind === 'open' ? state.handle : null;