A local-first note taking app
0

Configure Feed

Select the types of activity you want to include in your feed.

Add zen workspace mode

Ethan Graf (May 17, 2026, 1:23 PM EDT) 58741f6b efddb808

+556 -59
+77 -19
src/App.tsx
··· 1 - import { useEffect, useMemo, useState } from 'react'; 1 + import { useCallback, useEffect, useMemo, useState } from 'react'; 2 2 import { MemoryRouter, Route, Routes } from 'react-router-dom'; 3 + import { AppBottomBar } from './components/AppBottomBar'; 3 4 import { AppSidebar } from './components/AppSidebar'; 4 - import { SettingsModal } from './components/SettingsModal'; 5 5 import { AppTitleBar } from './components/AppTitleBar'; 6 + import { SettingsModal } from './components/SettingsModal'; 6 7 import { createHabitatDocsProvider } from './filesystem/providers/habitatDocsProvider'; 7 8 import { getRendererPlatform } from './lib/platform'; 8 9 import { AboutPage } from './pages/AboutPage'; 9 - import { HomePage } from './pages/HomePage'; 10 + import { 11 + DEFAULT_WORKSPACE_KIND, 12 + isWorkspaceKind, 13 + WorkspaceHost, 14 + type OpenDocRequest, 15 + type WorkspaceKind, 16 + } from './workspaces/workspace'; 17 + 18 + const WORKSPACE_KIND_STORAGE_KEY = 'textile.workspaceKind'; 19 + 20 + function loadInitialWorkspaceKind(): WorkspaceKind { 21 + if (typeof localStorage === 'undefined') return DEFAULT_WORKSPACE_KIND; 22 + try { 23 + const raw = localStorage.getItem(WORKSPACE_KIND_STORAGE_KEY); 24 + return isWorkspaceKind(raw) ? raw : DEFAULT_WORKSPACE_KIND; 25 + } catch { 26 + return DEFAULT_WORKSPACE_KIND; 27 + } 28 + } 10 29 11 30 export default function App() { 12 31 const [theme, setTheme] = useState<'light' | 'dark'>(() => { ··· 15 34 }); 16 35 const [sidebarOpen, setSidebarOpen] = useState(true); 17 36 const [settingsOpen, setSettingsOpen] = useState(false); 37 + const [workspaceKind, setWorkspaceKind] = useState<WorkspaceKind>( 38 + loadInitialWorkspaceKind, 39 + ); 40 + const [openRequest, setOpenRequest] = useState<OpenDocRequest | null>(null); 41 + const [openRequestId, setOpenRequestId] = useState(0); 42 + 18 43 const fileSystemProviders = useMemo(() => [createHabitatDocsProvider()], []); 19 44 // TODO: Remove this guard once non-macOS title bar behavior is implemented. 20 45 const isMac = getRendererPlatform() === 'darwin'; ··· 35 60 return () => unsubscribe?.(); 36 61 }, []); 37 62 63 + useEffect(() => { 64 + if (typeof localStorage === 'undefined') return; 65 + try { 66 + localStorage.setItem(WORKSPACE_KIND_STORAGE_KEY, workspaceKind); 67 + } catch { 68 + // Storage may be unavailable (e.g. private mode); preference simply won't persist. 69 + } 70 + }, [workspaceKind]); 71 + 72 + const handleOpenEntry = useCallback((request: OpenDocRequest) => { 73 + setOpenRequest(request); 74 + setOpenRequestId((id) => id + 1); 75 + }, []); 76 + 38 77 return ( 39 78 <MemoryRouter initialEntries={['/']}> 40 79 {/* TODO: Replace this unsupported screen with full non-macOS UI path. */} ··· 49 88 <AppTitleBar 50 89 sidebarOpen={sidebarOpen} 51 90 onToggleSidebar={() => setSidebarOpen((o) => !o)} 91 + workspaceKind={workspaceKind} 92 + onWorkspaceKindChange={setWorkspaceKind} 52 93 /> 53 - <div 54 - className="flex min-h-0 flex-1 pb-12" 55 - style={{ 56 - // Reserve the window’s title bar row; custom chrome is `position: fixed` in that region. 57 - marginTop: 58 - 'calc(env(titlebar-area-y, 0px) + env(titlebar-area-height, 40px))', 59 - }} 60 - > 61 - <AppSidebar open={sidebarOpen} providers={fileSystemProviders} /> 62 - <main className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden"> 63 - <Routes> 64 - <Route path="/" element={<HomePage />} /> 65 - <Route path="/about" element={<AboutPage />} /> 66 - </Routes> 67 - </main> 68 - </div> 94 + <AppBottomBar> 95 + <div 96 + className="flex min-h-0 flex-1 pb-12" 97 + style={{ 98 + // Reserve the window’s title bar row; custom chrome is `position: fixed` in that region. 99 + marginTop: 100 + 'calc(env(titlebar-area-y, 0px) + env(titlebar-area-height, 40px))', 101 + }} 102 + > 103 + <AppSidebar 104 + open={sidebarOpen} 105 + providers={fileSystemProviders} 106 + onOpenEntry={handleOpenEntry} 107 + /> 108 + <main className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden"> 109 + <Routes> 110 + <Route 111 + path="/" 112 + element={ 113 + <WorkspaceHost 114 + kind={workspaceKind} 115 + openRequestId={openRequestId} 116 + openRequest={openRequest} 117 + /> 118 + } 119 + /> 120 + <Route path="/about" element={<AboutPage />} /> 121 + </Routes> 122 + </main> 123 + </div> 124 + </AppBottomBar> 69 125 <SettingsModal 70 126 open={settingsOpen} 71 127 onClose={() => setSettingsOpen(false)} ··· 73 129 onToggleTheme={() => 74 130 setTheme((t) => (t === 'light' ? 'dark' : 'light')) 75 131 } 132 + workspaceKind={workspaceKind} 133 + onWorkspaceKindChange={setWorkspaceKind} 76 134 /> 77 135 </div> 78 136 )}
+28
src/components/AppBottomBar.tsx
··· 1 + import { useState, type ReactNode } from 'react'; 2 + import { WorkspaceBarTargetProvider } from '../workspaces/barSlot'; 3 + 4 + type AppBottomBarProps = { 5 + /** App tree that may render `<WorkspaceBarSlot>` to portal content into the bar. */ 6 + children: ReactNode; 7 + }; 8 + 9 + /** 10 + * Shell-owned status bar pinned to the bottom of the window. Renders an empty 11 + * strip by default; workspaces (or other shell features) contribute controls by 12 + * mounting `<WorkspaceBarSlot>` inside this provider's subtree. 13 + */ 14 + export function AppBottomBar({ children }: AppBottomBarProps) { 15 + const [target, setTarget] = useState<HTMLDivElement | null>(null); 16 + 17 + return ( 18 + <WorkspaceBarTargetProvider value={target}> 19 + {children} 20 + <div className="border-border bg-background/95 fixed right-0 bottom-0 left-0 z-30 flex h-12 items-center border-t"> 21 + <div 22 + ref={setTarget} 23 + className="flex min-w-0 flex-1 items-center gap-2 px-2" 24 + /> 25 + </div> 26 + </WorkspaceBarTargetProvider> 27 + ); 28 + }
+12 -4
src/components/AppSidebar.tsx
··· 1 1 import { useEffect, useMemo, useRef, useState } from 'react'; 2 2 import type { FileSystemEntry, FileSystemProvider } from '../filesystem/types'; 3 + import type { OpenDocRequest } from '../workspaces/workspace'; 3 4 4 5 type AppSidebarProps = { 5 6 open: boolean; 6 7 providers: FileSystemProvider[]; 8 + /** Emitted when the user picks an entry; the shell forwards it to the active workspace. */ 9 + onOpenEntry?: (request: OpenDocRequest) => void; 7 10 }; 8 11 9 12 type ListingState = { ··· 36 39 return { entries: [], loading: true, error: null }; 37 40 } 38 41 39 - export function AppSidebar({ open, providers }: AppSidebarProps) { 42 + export function AppSidebar({ open, providers, onOpenEntry }: AppSidebarProps) { 40 43 const [listings, setListings] = useState<Record<string, ListingState>>({}); 41 44 // TODO: When workspace tabs change the active document, update this selection to match. 42 45 const [selection, setSelection] = useState<Selection>(null); ··· 183 186 role="listitem" 184 187 title={entry.name} 185 188 aria-current={isSelected ? 'true' : undefined} 186 - onClick={() => 189 + onClick={() => { 187 190 setSelection({ 188 191 providerId: provider.id, 189 192 entryId: entry.id, 190 - }) 191 - } 193 + }); 194 + onOpenEntry?.({ 195 + providerId: provider.id, 196 + entryId: entry.id, 197 + title: entry.name, 198 + }); 199 + }} 192 200 className={`font-inherit focus-visible:ring-accent/50 flex w-full min-w-0 shrink-0 cursor-pointer items-center gap-2 rounded-md border-0 px-2 py-1.5 text-left text-sm outline-none select-none focus-visible:ring-2 ${ 193 201 isSelected 194 202 ? 'bg-border/50 text-foreground'
+12
src/components/AppTitleBar.tsx
··· 1 1 import type { CSSProperties } from 'react'; 2 + import type { WorkspaceKind } from '../workspaces/workspace'; 3 + import { WorkspaceMenu } from './WorkspaceMenu'; 2 4 3 5 type AppTitleBarProps = { 4 6 sidebarOpen: boolean; 5 7 onToggleSidebar: () => void; 8 + workspaceKind: WorkspaceKind; 9 + onWorkspaceKindChange: (kind: WorkspaceKind) => void; 6 10 }; 7 11 8 12 function SidebarToggleIcon({ className }: { className?: string }) { ··· 38 42 export function AppTitleBar({ 39 43 sidebarOpen, 40 44 onToggleSidebar, 45 + workspaceKind, 46 + onWorkspaceKindChange, 41 47 }: AppTitleBarProps) { 42 48 const noDrag = { WebkitAppRegion: 'no-drag' as const }; 43 49 ··· 58 64 > 59 65 <SidebarToggleIcon className="size-4" /> 60 66 </button> 67 + </div> 68 + <div className="flex h-full shrink-0 items-center gap-1 pr-1"> 69 + <WorkspaceMenu 70 + value={workspaceKind} 71 + onChange={onWorkspaceKindChange} 72 + /> 61 73 </div> 62 74 </header> 63 75 );
+46 -4
src/components/SettingsModal.tsx
··· 1 1 import { useState, type ReactNode } from 'react'; 2 2 import { LOGIN_NAME, useHabitatAuth } from '../auth/habitatAuth'; 3 + import { 4 + WORKSPACE_DESCRIPTORS, 5 + type WorkspaceKind, 6 + } from '../workspaces/workspace'; 3 7 import { Modal } from './Modal'; 4 8 import { SettingsPaneLayout, SettingsPaneSection } from './SettingsPane'; 5 9 ··· 8 12 onClose: () => void; 9 13 theme: 'light' | 'dark'; 10 14 onToggleTheme: () => void; 15 + workspaceKind: WorkspaceKind; 16 + onWorkspaceKindChange: (kind: WorkspaceKind) => void; 11 17 }; 12 18 13 19 type SettingsCategory = 'atmosphere' | 'appearance'; ··· 167 173 onClose, 168 174 theme, 169 175 onToggleTheme, 176 + workspaceKind, 177 + onWorkspaceKindChange, 170 178 }: SettingsModalProps) { 171 179 const [selectedCategory, setSelectedCategory] = 172 180 useState<SettingsCategory>('atmosphere'); ··· 219 227 {selectedCategory === 'atmosphere' ? ( 220 228 <AtmosphereAccountPane /> 221 229 ) : ( 222 - <div className="space-y-2"> 230 + <div className="space-y-4"> 223 231 <SettingsPaneSection title="Theme"> 224 232 <button 225 233 type="button" ··· 238 246 </span> 239 247 </button> 240 248 </SettingsPaneSection> 249 + <div className="border-border border-t pt-3"> 250 + <p className="text-foreground/70 text-sm">Workspace</p> 251 + <p className="text-foreground/60 mt-1 text-xs"> 252 + Choose how the main editing surface is laid out. Switching 253 + resets the open documents in the workspace. 254 + </p> 255 + <div 256 + role="radiogroup" 257 + aria-label="Workspace layout" 258 + className="mt-3 space-y-1" 259 + > 260 + {WORKSPACE_DESCRIPTORS.map((descriptor) => { 261 + const selected = descriptor.kind === workspaceKind; 262 + return ( 263 + <button 264 + key={descriptor.kind} 265 + type="button" 266 + role="radio" 267 + aria-checked={selected} 268 + onClick={() => onWorkspaceKindChange(descriptor.kind)} 269 + className={`focus-visible:ring-accent/50 flex w-full flex-col items-start gap-0.5 rounded-md border px-3 py-2 text-left outline-none focus-visible:ring-2 ${ 270 + selected 271 + ? 'border-border bg-border/40 text-foreground' 272 + : 'border-border/60 text-foreground/90 hover:bg-border/25' 273 + }`} 274 + > 275 + <span className="text-sm font-medium"> 276 + {descriptor.label} 277 + </span> 278 + <span className="text-foreground/60 text-xs leading-snug"> 279 + {descriptor.description} 280 + </span> 281 + </button> 282 + ); 283 + })} 284 + </div> 285 + </div> 241 286 {/* TODO: Add configurable theme transition controls with richer theme presets. */} 242 - <p className="text-foreground/60 text-xs"> 243 - TODO: Add transition controls when expanding themes. 244 - </p> 245 287 </div> 246 288 )} 247 289 </SettingsPaneLayout>
+100
src/components/WorkspaceMenu.tsx
··· 1 + import { useEffect, useRef, useState, type CSSProperties } from 'react'; 2 + import { 3 + WORKSPACE_DESCRIPTORS, 4 + type WorkspaceKind, 5 + } from '../workspaces/workspace'; 6 + 7 + type WorkspaceMenuProps = { 8 + value: WorkspaceKind; 9 + onChange: (kind: WorkspaceKind) => void; 10 + }; 11 + 12 + const noDrag: CSSProperties & { WebkitAppRegion?: 'no-drag' } = { 13 + WebkitAppRegion: 'no-drag', 14 + }; 15 + 16 + export function WorkspaceMenu({ value, onChange }: WorkspaceMenuProps) { 17 + const [open, setOpen] = useState(false); 18 + const rootRef = useRef<HTMLDivElement>(null); 19 + const current = WORKSPACE_DESCRIPTORS.find((d) => d.kind === value); 20 + 21 + useEffect(() => { 22 + if (!open) return; 23 + const onDocMouseDown = (event: MouseEvent) => { 24 + if (!rootRef.current) return; 25 + if (!rootRef.current.contains(event.target as Node)) { 26 + setOpen(false); 27 + } 28 + }; 29 + const onKey = (event: KeyboardEvent) => { 30 + if (event.key === 'Escape') setOpen(false); 31 + }; 32 + document.addEventListener('mousedown', onDocMouseDown); 33 + document.addEventListener('keydown', onKey); 34 + return () => { 35 + document.removeEventListener('mousedown', onDocMouseDown); 36 + document.removeEventListener('keydown', onKey); 37 + }; 38 + }, [open]); 39 + 40 + return ( 41 + <div ref={rootRef} className="relative" style={noDrag}> 42 + <button 43 + type="button" 44 + aria-haspopup="menu" 45 + aria-expanded={open} 46 + aria-label="Workspace" 47 + onClick={() => setOpen((o) => !o)} 48 + className="text-foreground/90 hover:bg-border/40 focus-visible:ring-accent/50 inline-flex items-center gap-1 rounded px-2 py-1 text-xs outline-none focus-visible:ring-2" 49 + > 50 + <span className="text-foreground/60">Workspace:</span> 51 + <span className="font-medium">{current?.label ?? value}</span> 52 + <svg 53 + className="size-4" 54 + viewBox="0 0 24 24" 55 + fill="none" 56 + stroke="currentColor" 57 + strokeWidth="2" 58 + strokeLinecap="round" 59 + strokeLinejoin="round" 60 + aria-hidden 61 + > 62 + <path d="M6 9l6 6 6-6" /> 63 + </svg> 64 + </button> 65 + {open ? ( 66 + <div 67 + role="menu" 68 + aria-label="Switch workspace" 69 + className="border-border bg-background absolute top-full right-0 z-50 mt-1 w-64 rounded-md border p-1 shadow-xl" 70 + > 71 + {WORKSPACE_DESCRIPTORS.map((descriptor) => { 72 + const selected = descriptor.kind === value; 73 + return ( 74 + <button 75 + key={descriptor.kind} 76 + type="button" 77 + role="menuitemradio" 78 + aria-checked={selected} 79 + onClick={() => { 80 + onChange(descriptor.kind); 81 + setOpen(false); 82 + }} 83 + className={`focus-visible:ring-accent/50 flex w-full flex-col items-start gap-0.5 rounded px-2 py-1.5 text-left outline-none focus-visible:ring-2 ${ 84 + selected 85 + ? 'bg-border/50 text-foreground' 86 + : 'text-foreground/90 hover:bg-border/40' 87 + }`} 88 + > 89 + <span className="text-xs font-medium">{descriptor.label}</span> 90 + <span className="text-foreground/60 text-[11px] leading-snug"> 91 + {descriptor.description} 92 + </span> 93 + </button> 94 + ); 95 + })} 96 + </div> 97 + ) : null} 98 + </div> 99 + ); 100 + }
-16
src/pages/HomePage.tsx
··· 1 - import { useState } from 'react'; 2 - import { WorkspaceTiling } from '../tiling/WorkspaceTiling'; 3 - 4 - const INITIAL_NOTE = `# Welcome to Textile 5 - 6 - This is a **local-first** note. The editor uses Markdown and is built for a smooth typing experience. 7 - 8 - - Edit this buffer freely — it stays in memory until you wire up a vault on disk. 9 - - Later you can plug in CRDT sync without throwing away the editor surface. 10 - `; 11 - 12 - export function HomePage() { 13 - const [note] = useState(INITIAL_NOTE); 14 - 15 - return <WorkspaceTiling initialNote={note} />; 16 - }
+35 -16
src/tiling/WorkspaceTiling.tsx src/workspaces/tiling/tiling.tsx
··· 1 - import { ReactNode, useMemo, useRef, useState } from 'react'; 2 - import { EditPad } from '../components/EditPad'; 1 + import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; 2 + import { EditPad } from '../../components/EditPad'; 3 3 import { 4 4 clampRatioForAxis, 5 5 createInitialTree, ··· 9 9 SplitOrientation, 10 10 TilingNode, 11 11 updateSplitRatio, 12 - } from './model'; 13 - import { useResizeObserver } from './useResizeObserver'; 12 + } from '../../tiling/model'; 13 + import { useResizeObserver } from '../../tiling/useResizeObserver'; 14 + import { WorkspaceBarSlot } from '../barSlot'; 15 + import type { WorkspaceProps } from '../workspace'; 14 16 15 - type WorkspaceTilingProps = { 16 - initialNote: string; 17 - }; 17 + const INITIAL_NOTE = `# Welcome to Textile 18 + 19 + This is a **local-first** note. The editor uses Markdown and is built for a smooth typing experience. 20 + 21 + - Edit this buffer freely — it stays in memory until you wire up a vault on disk. 22 + - Later you can plug in CRDT sync without throwing away the editor surface. 23 + `; 18 24 19 25 type SplitterProps = { 20 26 node: SplitNode; ··· 102 108 ); 103 109 } 104 110 105 - export function WorkspaceTiling({ initialNote }: WorkspaceTilingProps) { 111 + export function TilingWorkspace({ openRequestId, openRequest }: WorkspaceProps) { 106 112 const nextIdRef = useRef(2); 107 113 const makeId = (prefix: string) => `${prefix}-${nextIdRef.current++}`; 108 114 ··· 111 117 ); 112 118 const [activeTileId, setActiveTileId] = useState('tile-1'); 113 119 const [tileNotes, setTileNotes] = useState<Record<string, string>>({ 114 - 'tile-1': initialNote, 120 + 'tile-1': INITIAL_NOTE, 115 121 }); 116 122 123 + const lastSeenIdRef = useRef(0); 124 + useEffect(() => { 125 + if (openRequestId === lastSeenIdRef.current) return; 126 + lastSeenIdRef.current = openRequestId; 127 + if (!openRequest) return; 128 + // TODO(tiling): Open into a new tab on the active tile once tabs land; for 129 + // now we replace the active tile's note in parity with zen's silent open. 130 + setTileNotes((prev) => ({ 131 + ...prev, 132 + [activeTileId]: `# ${openRequest.title}\n\n_(content not yet loaded)_\n`, 133 + })); 134 + }, [openRequestId, openRequest, activeTileId]); 135 + 117 136 const commandIds = useMemo( 118 137 () => ({ 119 138 splitRight: 'workspace.splitActiveRight', ··· 134 153 setActiveTileId(result.newTileId); 135 154 setTileNotes((prev) => ({ 136 155 ...prev, 137 - [result.newTileId]: prev[activeTileId] ?? initialNote, 156 + [result.newTileId]: prev[activeTileId] ?? INITIAL_NOTE, 138 157 })); 139 158 }; 140 159 ··· 189 208 <div className="h-full min-h-0 w-full overflow-hidden p-2"> 190 209 {renderNode(tree)} 191 210 </div> 192 - <div className="border-border bg-background/90 fixed right-0 bottom-0 left-0 z-30 flex h-12 items-center border-t"> 193 - <div className="flex min-w-0 flex-1 items-center gap-2 px-2"> 211 + <WorkspaceBarSlot> 212 + <div className="flex min-w-0 flex-1 items-center gap-2"> 194 213 <button 195 214 type="button" 196 215 onClick={() => applySplit('row')} 197 216 data-command-id={commandIds.splitRight} 198 - className="border-border bg-background text-foreground hover:bg-border/30 rounded border px-2 py-1 text-xs" 217 + className="border-border bg-background text-foreground hover:bg-border/30 focus-visible:ring-accent/50 shrink-0 rounded border px-2 py-1 text-xs outline-none focus-visible:ring-2" 199 218 > 200 219 Split Right 201 220 </button> ··· 203 222 type="button" 204 223 onClick={() => applySplit('column')} 205 224 data-command-id={commandIds.splitDown} 206 - className="border-border bg-background text-foreground hover:bg-border/30 rounded border px-2 py-1 text-xs" 225 + className="border-border bg-background text-foreground hover:bg-border/30 focus-visible:ring-accent/50 shrink-0 rounded border px-2 py-1 text-xs outline-none focus-visible:ring-2" 207 226 > 208 227 Split Down 209 228 </button> 210 - <div className="text-foreground/60 ml-2 text-xs"> 229 + <div className="text-foreground/60 ml-2 truncate text-xs"> 211 230 Active: {activeTileId} 212 231 </div> 213 232 </div> 214 - </div> 233 + </WorkspaceBarSlot> 215 234 </> 216 235 ); 217 236 }
+17
src/workspaces/barSlot.tsx
··· 1 + import { createContext, useContext, type ReactNode } from 'react'; 2 + import { createPortal } from 'react-dom'; 3 + 4 + /** 5 + * Shell paints the bottom bar strip; workspaces portal their contextual controls 6 + * into it via {@link WorkspaceBarSlot}. Provider value is the DOM node owned by 7 + * the shell `AppBottomBar`. 8 + */ 9 + const BarTargetContext = createContext<HTMLElement | null>(null); 10 + 11 + export const WorkspaceBarTargetProvider = BarTargetContext.Provider; 12 + 13 + export function WorkspaceBarSlot({ children }: { children: ReactNode }) { 14 + const target = useContext(BarTargetContext); 15 + if (!target) return null; 16 + return createPortal(children, target); 17 + }
+108
src/workspaces/workspace.tsx
··· 1 + import type { ComponentType } from 'react'; 2 + import { TilingWorkspace } from './tiling/tiling'; 3 + import { ZenWorkspace } from './zen/zen'; 4 + 5 + export type WorkspaceKind = 'tiling' | 'zen'; 6 + 7 + /** 8 + * Intent from the shell (e.g. sidebar) asking the active workspace to open a 9 + * document. 10 + * 11 + * TODO(naming): `providerId` / `entryId` come from the FileSystemProvider 12 + * abstraction in `src/filesystem/types.ts`. As we add non-filesystem sources 13 + * (search results, recent items, deep links) these names start to lie. Options 14 + * we may want to workshop: `sourceId` + `documentId`, a single opaque `uri` 15 + * (e.g. `habitat-docs://<entry>`), or a tagged union per source kind. Leaving 16 + * the FS-flavoured names for now since the only source today *is* the 17 + * filesystem sidebar. 18 + */ 19 + export type OpenDocRequest = { 20 + /** 21 + * Identifies the source that owns the document. Today this is a 22 + * `FileSystemProvider.id` such as `"habitat-docs"`; the shell uses it to 23 + * route reads/writes back to the right provider. 24 + */ 25 + providerId: string; 26 + /** 27 + * Identifies the specific document within that source. For 28 + * `habitat-docs` this is an AT-protocol URI; for future providers it could 29 + * be a file path, blob CID, etc. Opaque to the workspace. 30 + */ 31 + entryId: string; 32 + /** Human-readable label used for tab/title chrome. */ 33 + title: string; 34 + }; 35 + 36 + export type WorkspaceProps = { 37 + /** 38 + * Monotonically bumped each time the shell issues a new open intent. 39 + * Workspaces detect "a new open arrived" by comparing against their 40 + * last-seen id, which avoids re-opening on unrelated re-renders. 41 + */ 42 + openRequestId: number; 43 + openRequest: OpenDocRequest | null; 44 + }; 45 + 46 + export type WorkspaceComponent = ComponentType<WorkspaceProps>; 47 + 48 + export type WorkspaceDescriptor = { 49 + kind: WorkspaceKind; 50 + label: string; 51 + description: string; 52 + Component: WorkspaceComponent; 53 + }; 54 + 55 + export const WORKSPACE_DESCRIPTORS: readonly WorkspaceDescriptor[] = [ 56 + { 57 + kind: 'tiling', 58 + label: 'Tiling', 59 + description: 'Split-pane editor with resizable tiles and split controls.', 60 + Component: TilingWorkspace, 61 + }, 62 + { 63 + kind: 'zen', 64 + label: 'Zen', 65 + description: 'Single editor focused on one document at a time.', 66 + Component: ZenWorkspace, 67 + }, 68 + ]; 69 + 70 + const BY_KIND = new Map(WORKSPACE_DESCRIPTORS.map((d) => [d.kind, d])); 71 + 72 + export const DEFAULT_WORKSPACE_KIND: WorkspaceKind = 'tiling'; 73 + 74 + export function getWorkspaceDescriptor(kind: WorkspaceKind): WorkspaceDescriptor { 75 + const d = BY_KIND.get(kind); 76 + if (!d) throw new Error(`Unknown workspace kind: ${kind}`); 77 + return d; 78 + } 79 + 80 + export function isWorkspaceKind(value: unknown): value is WorkspaceKind { 81 + return typeof value === 'string' && BY_KIND.has(value as WorkspaceKind); 82 + } 83 + 84 + type WorkspaceHostProps = { 85 + kind: WorkspaceKind; 86 + openRequestId: number; 87 + openRequest: OpenDocRequest | null; 88 + }; 89 + 90 + /** 91 + * Mounts the active workspace. The `key={kind}` remount on switch is 92 + * intentional: each workspace owns its own state, and resetting on switch 93 + * keeps the abstraction boundary clean (no cross-kind state to reason about). 94 + */ 95 + export function WorkspaceHost({ 96 + kind, 97 + openRequestId, 98 + openRequest, 99 + }: WorkspaceHostProps) { 100 + const { Component } = getWorkspaceDescriptor(kind); 101 + return ( 102 + <Component 103 + key={kind} 104 + openRequestId={openRequestId} 105 + openRequest={openRequest} 106 + /> 107 + ); 108 + }
+40
src/workspaces/zen/doc.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { applyOpenRequest, makeZenDocFromRequest } from './doc'; 4 + 5 + const req = (overrides: Partial<{ providerId: string; entryId: string; title: string }> = {}) => ({ 6 + providerId: 'habitat-docs', 7 + entryId: 'at://did:plc:abc/network.habitat.docs/123', 8 + title: 'Notes', 9 + ...overrides, 10 + }); 11 + 12 + describe('makeZenDocFromRequest', () => { 13 + it('namespaces id by provider and entry', () => { 14 + const doc = makeZenDocFromRequest(req({ providerId: 'p', entryId: 'e' })); 15 + expect(doc.id).toBe('p:e'); 16 + }); 17 + 18 + it('uses the request title as the doc title', () => { 19 + expect(makeZenDocFromRequest(req({ title: 'Hello' })).title).toBe('Hello'); 20 + }); 21 + }); 22 + 23 + describe('applyOpenRequest', () => { 24 + it('returns current doc when request is null', () => { 25 + const current = makeZenDocFromRequest(req({ title: 'Keep me' })); 26 + expect(applyOpenRequest(current, null)).toBe(current); 27 + }); 28 + 29 + it('opens a doc when there was none', () => { 30 + const next = applyOpenRequest(null, req({ title: 'First' })); 31 + expect(next?.title).toBe('First'); 32 + }); 33 + 34 + it('silently replaces an existing doc with the new one', () => { 35 + const current = makeZenDocFromRequest(req({ entryId: 'one', title: 'One' })); 36 + const next = applyOpenRequest(current, req({ entryId: 'two', title: 'Two' })); 37 + expect(next?.id).toBe('habitat-docs:two'); 38 + expect(next?.title).toBe('Two'); 39 + }); 40 + });
+30
src/workspaces/zen/doc.ts
··· 1 + import type { OpenDocRequest } from '../workspace'; 2 + 3 + export type ZenDoc = { 4 + /** Stable handle derived from the source; not shown in the UI. */ 5 + id: string; 6 + title: string; 7 + content: string; 8 + }; 9 + 10 + export function makeZenDocFromRequest(req: OpenDocRequest): ZenDoc { 11 + return { 12 + id: `${req.providerId}:${req.entryId}`, 13 + title: req.title, 14 + // TODO: Replace placeholder body with real provider content once a read API exists. 15 + content: `# ${req.title}\n\n_(content not yet loaded)_\n`, 16 + }; 17 + } 18 + 19 + /** 20 + * Silent replace: any non-null open request supersedes the current doc with no 21 + * confirmation. Acceptable while Zen content is in-memory only; revisit before 22 + * Zen edits hit real persistence. 23 + */ 24 + export function applyOpenRequest( 25 + current: ZenDoc | null, 26 + req: OpenDocRequest | null, 27 + ): ZenDoc | null { 28 + if (!req) return current; 29 + return makeZenDocFromRequest(req); 30 + }
+51
src/workspaces/zen/zen.tsx
··· 1 + import { useEffect, useRef, useState } from 'react'; 2 + import { EditPad } from '../../components/EditPad'; 3 + import { WorkspaceBarSlot } from '../barSlot'; 4 + import type { WorkspaceProps } from '../workspace'; 5 + import { applyOpenRequest, type ZenDoc } from './doc'; 6 + 7 + export function ZenWorkspace({ openRequestId, openRequest }: WorkspaceProps) { 8 + const [doc, setDoc] = useState<ZenDoc | null>(null); 9 + const lastSeenIdRef = useRef(0); 10 + 11 + useEffect(() => { 12 + if (openRequestId === lastSeenIdRef.current) return; 13 + lastSeenIdRef.current = openRequestId; 14 + setDoc((cur) => applyOpenRequest(cur, openRequest)); 15 + }, [openRequestId, openRequest]); 16 + 17 + return ( 18 + <> 19 + <div className="flex h-full min-h-0 w-full flex-col overflow-hidden p-2"> 20 + {doc ? ( 21 + <EditPad 22 + value={doc.content} 23 + onChange={(next) => 24 + setDoc((cur) => (cur ? { ...cur, content: next } : cur)) 25 + } 26 + placeholder="Start writing…" 27 + className="min-h-0 flex-1" 28 + /> 29 + ) : ( 30 + <div className="border-border text-foreground/60 flex h-full min-h-0 w-full items-center justify-center rounded-md border border-dashed p-5 text-center text-sm"> 31 + Select a file from the sidebar to open it here. 32 + </div> 33 + )} 34 + </div> 35 + <WorkspaceBarSlot> 36 + <div className="text-foreground/70 min-w-0 flex-1 truncate text-xs"> 37 + {doc ? doc.title : 'Zen · no document open'} 38 + </div> 39 + {doc ? ( 40 + <button 41 + type="button" 42 + onClick={() => setDoc(null)} 43 + className="border-border bg-background text-foreground hover:bg-border/30 focus-visible:ring-accent/50 shrink-0 rounded border px-2 py-1 text-xs outline-none focus-visible:ring-2" 44 + > 45 + Close 46 + </button> 47 + ) : null} 48 + </WorkspaceBarSlot> 49 + </> 50 + ); 51 + }