diff --git a/src/App.tsx b/src/App.tsx index f9e519a..98f2b28 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,31 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { AppBottomBar } from './components/AppBottomBar'; import { AppSidebar } from './components/AppSidebar'; -import { SettingsModal } from './components/SettingsModal'; import { AppTitleBar } from './components/AppTitleBar'; +import { SettingsModal } from './components/SettingsModal'; import { createHabitatDocsProvider } from './filesystem/providers/habitatDocsProvider'; import { getRendererPlatform } from './lib/platform'; import { AboutPage } from './pages/AboutPage'; -import { HomePage } from './pages/HomePage'; +import { + DEFAULT_WORKSPACE_KIND, + isWorkspaceKind, + WorkspaceHost, + type OpenDocRequest, + type WorkspaceKind, +} from './workspaces/workspace'; + +const WORKSPACE_KIND_STORAGE_KEY = 'textile.workspaceKind'; + +function loadInitialWorkspaceKind(): WorkspaceKind { + if (typeof localStorage === 'undefined') return DEFAULT_WORKSPACE_KIND; + try { + const raw = localStorage.getItem(WORKSPACE_KIND_STORAGE_KEY); + return isWorkspaceKind(raw) ? raw : DEFAULT_WORKSPACE_KIND; + } catch { + return DEFAULT_WORKSPACE_KIND; + } +} export default function App() { const [theme, setTheme] = useState<'light' | 'dark'>(() => { @@ -15,6 +34,12 @@ export default function App() { }); const [sidebarOpen, setSidebarOpen] = useState(true); const [settingsOpen, setSettingsOpen] = useState(false); + const [workspaceKind, setWorkspaceKind] = useState( + loadInitialWorkspaceKind, + ); + const [openRequest, setOpenRequest] = useState(null); + const [openRequestId, setOpenRequestId] = useState(0); + const fileSystemProviders = useMemo(() => [createHabitatDocsProvider()], []); // TODO: Remove this guard once non-macOS title bar behavior is implemented. const isMac = getRendererPlatform() === 'darwin'; @@ -35,6 +60,20 @@ export default function App() { return () => unsubscribe?.(); }, []); + useEffect(() => { + if (typeof localStorage === 'undefined') return; + try { + localStorage.setItem(WORKSPACE_KIND_STORAGE_KEY, workspaceKind); + } catch { + // Storage may be unavailable (e.g. private mode); preference simply won't persist. + } + }, [workspaceKind]); + + const handleOpenEntry = useCallback((request: OpenDocRequest) => { + setOpenRequest(request); + setOpenRequestId((id) => id + 1); + }, []); + return ( {/* TODO: Replace this unsupported screen with full non-macOS UI path. */} @@ -49,23 +88,40 @@ export default function App() { setSidebarOpen((o) => !o)} + workspaceKind={workspaceKind} + onWorkspaceKindChange={setWorkspaceKind} /> -
- -
- - } /> - } /> - -
-
+ +
+ +
+ + + } + /> + } /> + +
+
+
setSettingsOpen(false)} @@ -73,6 +129,8 @@ export default function App() { onToggleTheme={() => setTheme((t) => (t === 'light' ? 'dark' : 'light')) } + workspaceKind={workspaceKind} + onWorkspaceKindChange={setWorkspaceKind} /> )} diff --git a/src/components/AppBottomBar.tsx b/src/components/AppBottomBar.tsx new file mode 100644 index 0000000..bcafcf3 --- /dev/null +++ b/src/components/AppBottomBar.tsx @@ -0,0 +1,28 @@ +import { useState, type ReactNode } from 'react'; +import { WorkspaceBarTargetProvider } from '../workspaces/barSlot'; + +type AppBottomBarProps = { + /** App tree that may render `` to portal content into the bar. */ + children: ReactNode; +}; + +/** + * Shell-owned status bar pinned to the bottom of the window. Renders an empty + * strip by default; workspaces (or other shell features) contribute controls by + * mounting `` inside this provider's subtree. + */ +export function AppBottomBar({ children }: AppBottomBarProps) { + const [target, setTarget] = useState(null); + + return ( + + {children} +
+
+
+ + ); +} diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 98790c4..6dd8505 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -1,9 +1,12 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { FileSystemEntry, FileSystemProvider } from '../filesystem/types'; +import type { OpenDocRequest } from '../workspaces/workspace'; type AppSidebarProps = { open: boolean; providers: FileSystemProvider[]; + /** Emitted when the user picks an entry; the shell forwards it to the active workspace. */ + onOpenEntry?: (request: OpenDocRequest) => void; }; type ListingState = { @@ -36,7 +39,7 @@ function defaultListing(): ListingState { return { entries: [], loading: true, error: null }; } -export function AppSidebar({ open, providers }: AppSidebarProps) { +export function AppSidebar({ open, providers, onOpenEntry }: AppSidebarProps) { const [listings, setListings] = useState>({}); // TODO: When workspace tabs change the active document, update this selection to match. const [selection, setSelection] = useState(null); @@ -183,12 +186,17 @@ export function AppSidebar({ open, providers }: AppSidebarProps) { role="listitem" title={entry.name} aria-current={isSelected ? 'true' : undefined} - onClick={() => + onClick={() => { setSelection({ providerId: provider.id, entryId: entry.id, - }) - } + }); + onOpenEntry?.({ + providerId: provider.id, + entryId: entry.id, + title: entry.name, + }); + }} 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 ${ isSelected ? 'bg-border/50 text-foreground' diff --git a/src/components/AppTitleBar.tsx b/src/components/AppTitleBar.tsx index b906f9e..25f755f 100644 --- a/src/components/AppTitleBar.tsx +++ b/src/components/AppTitleBar.tsx @@ -1,8 +1,12 @@ import type { CSSProperties } from 'react'; +import type { WorkspaceKind } from '../workspaces/workspace'; +import { WorkspaceMenu } from './WorkspaceMenu'; type AppTitleBarProps = { sidebarOpen: boolean; onToggleSidebar: () => void; + workspaceKind: WorkspaceKind; + onWorkspaceKindChange: (kind: WorkspaceKind) => void; }; function SidebarToggleIcon({ className }: { className?: string }) { @@ -38,6 +42,8 @@ const macTitleBarStyle: CSSProperties & { WebkitAppRegion?: 'drag' } = { export function AppTitleBar({ sidebarOpen, onToggleSidebar, + workspaceKind, + onWorkspaceKindChange, }: AppTitleBarProps) { const noDrag = { WebkitAppRegion: 'no-drag' as const }; @@ -59,6 +65,12 @@ export function AppTitleBar({
+
+ +
); } diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index c7ab15e..8737da2 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,5 +1,9 @@ import { useState, type ReactNode } from 'react'; import { LOGIN_NAME, useHabitatAuth } from '../auth/habitatAuth'; +import { + WORKSPACE_DESCRIPTORS, + type WorkspaceKind, +} from '../workspaces/workspace'; import { Modal } from './Modal'; import { SettingsPaneLayout, SettingsPaneSection } from './SettingsPane'; @@ -8,6 +12,8 @@ type SettingsModalProps = { onClose: () => void; theme: 'light' | 'dark'; onToggleTheme: () => void; + workspaceKind: WorkspaceKind; + onWorkspaceKindChange: (kind: WorkspaceKind) => void; }; type SettingsCategory = 'atmosphere' | 'appearance'; @@ -167,6 +173,8 @@ export function SettingsModal({ onClose, theme, onToggleTheme, + workspaceKind, + onWorkspaceKindChange, }: SettingsModalProps) { const [selectedCategory, setSelectedCategory] = useState('atmosphere'); @@ -219,7 +227,7 @@ export function SettingsModal({ {selectedCategory === 'atmosphere' ? ( ) : ( -
+
+
+

Workspace

+

+ Choose how the main editing surface is laid out. Switching + resets the open documents in the workspace. +

+
+ {WORKSPACE_DESCRIPTORS.map((descriptor) => { + const selected = descriptor.kind === workspaceKind; + return ( + + ); + })} +
+
{/* TODO: Add configurable theme transition controls with richer theme presets. */} -

- TODO: Add transition controls when expanding themes. -

)} diff --git a/src/components/WorkspaceMenu.tsx b/src/components/WorkspaceMenu.tsx new file mode 100644 index 0000000..9016692 --- /dev/null +++ b/src/components/WorkspaceMenu.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef, useState, type CSSProperties } from 'react'; +import { + WORKSPACE_DESCRIPTORS, + type WorkspaceKind, +} from '../workspaces/workspace'; + +type WorkspaceMenuProps = { + value: WorkspaceKind; + onChange: (kind: WorkspaceKind) => void; +}; + +const noDrag: CSSProperties & { WebkitAppRegion?: 'no-drag' } = { + WebkitAppRegion: 'no-drag', +}; + +export function WorkspaceMenu({ value, onChange }: WorkspaceMenuProps) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const current = WORKSPACE_DESCRIPTORS.find((d) => d.kind === value); + + useEffect(() => { + if (!open) return; + const onDocMouseDown = (event: MouseEvent) => { + if (!rootRef.current) return; + if (!rootRef.current.contains(event.target as Node)) { + setOpen(false); + } + }; + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + document.addEventListener('mousedown', onDocMouseDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDocMouseDown); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + return ( +
+ + {open ? ( +
+ {WORKSPACE_DESCRIPTORS.map((descriptor) => { + const selected = descriptor.kind === value; + return ( + + ); + })} +
+ ) : null} +
+ ); +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx deleted file mode 100644 index 8875eea..0000000 --- a/src/pages/HomePage.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useState } from 'react'; -import { WorkspaceTiling } from '../tiling/WorkspaceTiling'; - -const INITIAL_NOTE = `# Welcome to Textile - -This is a **local-first** note. The editor uses Markdown and is built for a smooth typing experience. - -- Edit this buffer freely — it stays in memory until you wire up a vault on disk. -- Later you can plug in CRDT sync without throwing away the editor surface. -`; - -export function HomePage() { - const [note] = useState(INITIAL_NOTE); - - return ; -} diff --git a/src/workspaces/barSlot.tsx b/src/workspaces/barSlot.tsx new file mode 100644 index 0000000..cf97c3b --- /dev/null +++ b/src/workspaces/barSlot.tsx @@ -0,0 +1,17 @@ +import { createContext, useContext, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; + +/** + * Shell paints the bottom bar strip; workspaces portal their contextual controls + * into it via {@link WorkspaceBarSlot}. Provider value is the DOM node owned by + * the shell `AppBottomBar`. + */ +const BarTargetContext = createContext(null); + +export const WorkspaceBarTargetProvider = BarTargetContext.Provider; + +export function WorkspaceBarSlot({ children }: { children: ReactNode }) { + const target = useContext(BarTargetContext); + if (!target) return null; + return createPortal(children, target); +} diff --git a/src/tiling/WorkspaceTiling.tsx b/src/workspaces/tiling/tiling.tsx similarity index 76% rename from src/tiling/WorkspaceTiling.tsx rename to src/workspaces/tiling/tiling.tsx index 81ad66d..ef1fd5c 100644 --- a/src/tiling/WorkspaceTiling.tsx +++ b/src/workspaces/tiling/tiling.tsx @@ -1,5 +1,5 @@ -import { ReactNode, useMemo, useRef, useState } from 'react'; -import { EditPad } from '../components/EditPad'; +import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; +import { EditPad } from '../../components/EditPad'; import { clampRatioForAxis, createInitialTree, @@ -9,12 +9,18 @@ import { SplitOrientation, TilingNode, updateSplitRatio, -} from './model'; -import { useResizeObserver } from './useResizeObserver'; +} from '../../tiling/model'; +import { useResizeObserver } from '../../tiling/useResizeObserver'; +import { WorkspaceBarSlot } from '../barSlot'; +import type { WorkspaceProps } from '../workspace'; -type WorkspaceTilingProps = { - initialNote: string; -}; +const INITIAL_NOTE = `# Welcome to Textile + +This is a **local-first** note. The editor uses Markdown and is built for a smooth typing experience. + +- Edit this buffer freely — it stays in memory until you wire up a vault on disk. +- Later you can plug in CRDT sync without throwing away the editor surface. +`; type SplitterProps = { node: SplitNode; @@ -102,7 +108,7 @@ function SplitterLayout({ node, onRatioChange, renderNode }: SplitterProps) { ); } -export function WorkspaceTiling({ initialNote }: WorkspaceTilingProps) { +export function TilingWorkspace({ openRequestId, openRequest }: WorkspaceProps) { const nextIdRef = useRef(2); const makeId = (prefix: string) => `${prefix}-${nextIdRef.current++}`; @@ -111,9 +117,22 @@ export function WorkspaceTiling({ initialNote }: WorkspaceTilingProps) { ); const [activeTileId, setActiveTileId] = useState('tile-1'); const [tileNotes, setTileNotes] = useState>({ - 'tile-1': initialNote, + 'tile-1': INITIAL_NOTE, }); + const lastSeenIdRef = useRef(0); + useEffect(() => { + if (openRequestId === lastSeenIdRef.current) return; + lastSeenIdRef.current = openRequestId; + if (!openRequest) return; + // TODO(tiling): Open into a new tab on the active tile once tabs land; for + // now we replace the active tile's note in parity with zen's silent open. + setTileNotes((prev) => ({ + ...prev, + [activeTileId]: `# ${openRequest.title}\n\n_(content not yet loaded)_\n`, + })); + }, [openRequestId, openRequest, activeTileId]); + const commandIds = useMemo( () => ({ splitRight: 'workspace.splitActiveRight', @@ -134,7 +153,7 @@ export function WorkspaceTiling({ initialNote }: WorkspaceTilingProps) { setActiveTileId(result.newTileId); setTileNotes((prev) => ({ ...prev, - [result.newTileId]: prev[activeTileId] ?? initialNote, + [result.newTileId]: prev[activeTileId] ?? INITIAL_NOTE, })); }; @@ -189,13 +208,13 @@ export function WorkspaceTiling({ initialNote }: WorkspaceTilingProps) {
{renderNode(tree)}
-
-
+ +
@@ -203,15 +222,15 @@ export function WorkspaceTiling({ initialNote }: WorkspaceTilingProps) { type="button" onClick={() => applySplit('column')} data-command-id={commandIds.splitDown} - className="border-border bg-background text-foreground hover:bg-border/30 rounded border px-2 py-1 text-xs" + 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" > Split Down -
+
Active: {activeTileId}
-
+
); } diff --git a/src/workspaces/workspace.tsx b/src/workspaces/workspace.tsx new file mode 100644 index 0000000..fd71581 --- /dev/null +++ b/src/workspaces/workspace.tsx @@ -0,0 +1,108 @@ +import type { ComponentType } from 'react'; +import { TilingWorkspace } from './tiling/tiling'; +import { ZenWorkspace } from './zen/zen'; + +export type WorkspaceKind = 'tiling' | 'zen'; + +/** + * Intent from the shell (e.g. sidebar) asking the active workspace to open a + * document. + * + * TODO(naming): `providerId` / `entryId` come from the FileSystemProvider + * abstraction in `src/filesystem/types.ts`. As we add non-filesystem sources + * (search results, recent items, deep links) these names start to lie. Options + * we may want to workshop: `sourceId` + `documentId`, a single opaque `uri` + * (e.g. `habitat-docs://`), or a tagged union per source kind. Leaving + * the FS-flavoured names for now since the only source today *is* the + * filesystem sidebar. + */ +export type OpenDocRequest = { + /** + * Identifies the source that owns the document. Today this is a + * `FileSystemProvider.id` such as `"habitat-docs"`; the shell uses it to + * route reads/writes back to the right provider. + */ + providerId: string; + /** + * Identifies the specific document within that source. For + * `habitat-docs` this is an AT-protocol URI; for future providers it could + * be a file path, blob CID, etc. Opaque to the workspace. + */ + entryId: string; + /** Human-readable label used for tab/title chrome. */ + title: string; +}; + +export type WorkspaceProps = { + /** + * Monotonically bumped each time the shell issues a new open intent. + * Workspaces detect "a new open arrived" by comparing against their + * last-seen id, which avoids re-opening on unrelated re-renders. + */ + openRequestId: number; + openRequest: OpenDocRequest | null; +}; + +export type WorkspaceComponent = ComponentType; + +export type WorkspaceDescriptor = { + kind: WorkspaceKind; + label: string; + description: string; + Component: WorkspaceComponent; +}; + +export const WORKSPACE_DESCRIPTORS: readonly WorkspaceDescriptor[] = [ + { + kind: 'tiling', + label: 'Tiling', + description: 'Split-pane editor with resizable tiles and split controls.', + Component: TilingWorkspace, + }, + { + kind: 'zen', + label: 'Zen', + description: 'Single editor focused on one document at a time.', + Component: ZenWorkspace, + }, +]; + +const BY_KIND = new Map(WORKSPACE_DESCRIPTORS.map((d) => [d.kind, d])); + +export const DEFAULT_WORKSPACE_KIND: WorkspaceKind = 'tiling'; + +export function getWorkspaceDescriptor(kind: WorkspaceKind): WorkspaceDescriptor { + const d = BY_KIND.get(kind); + if (!d) throw new Error(`Unknown workspace kind: ${kind}`); + return d; +} + +export function isWorkspaceKind(value: unknown): value is WorkspaceKind { + return typeof value === 'string' && BY_KIND.has(value as WorkspaceKind); +} + +type WorkspaceHostProps = { + kind: WorkspaceKind; + openRequestId: number; + openRequest: OpenDocRequest | null; +}; + +/** + * 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). + */ +export function WorkspaceHost({ + kind, + openRequestId, + openRequest, +}: WorkspaceHostProps) { + const { Component } = getWorkspaceDescriptor(kind); + return ( + + ); +} diff --git a/src/workspaces/zen/doc.test.ts b/src/workspaces/zen/doc.test.ts new file mode 100644 index 0000000..e4ae9a0 --- /dev/null +++ b/src/workspaces/zen/doc.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { applyOpenRequest, makeZenDocFromRequest } from './doc'; + +const req = (overrides: Partial<{ providerId: string; entryId: string; title: string }> = {}) => ({ + providerId: 'habitat-docs', + entryId: 'at://did:plc:abc/network.habitat.docs/123', + title: 'Notes', + ...overrides, +}); + +describe('makeZenDocFromRequest', () => { + it('namespaces id by provider and entry', () => { + const doc = makeZenDocFromRequest(req({ providerId: 'p', entryId: 'e' })); + expect(doc.id).toBe('p:e'); + }); + + it('uses the request title as the doc title', () => { + expect(makeZenDocFromRequest(req({ title: 'Hello' })).title).toBe('Hello'); + }); +}); + +describe('applyOpenRequest', () => { + it('returns current doc when request is null', () => { + const current = makeZenDocFromRequest(req({ title: 'Keep me' })); + expect(applyOpenRequest(current, null)).toBe(current); + }); + + it('opens a doc when there was none', () => { + const next = applyOpenRequest(null, req({ title: 'First' })); + expect(next?.title).toBe('First'); + }); + + it('silently replaces an existing doc with the new one', () => { + const current = makeZenDocFromRequest(req({ entryId: 'one', title: 'One' })); + const next = applyOpenRequest(current, req({ entryId: 'two', title: 'Two' })); + expect(next?.id).toBe('habitat-docs:two'); + expect(next?.title).toBe('Two'); + }); +}); diff --git a/src/workspaces/zen/doc.ts b/src/workspaces/zen/doc.ts new file mode 100644 index 0000000..45185f0 --- /dev/null +++ b/src/workspaces/zen/doc.ts @@ -0,0 +1,30 @@ +import type { OpenDocRequest } from '../workspace'; + +export type ZenDoc = { + /** Stable handle derived from the source; not shown in the UI. */ + id: string; + title: string; + content: string; +}; + +export function makeZenDocFromRequest(req: OpenDocRequest): ZenDoc { + return { + id: `${req.providerId}:${req.entryId}`, + title: req.title, + // TODO: Replace placeholder body with real provider content once a read API exists. + content: `# ${req.title}\n\n_(content not yet loaded)_\n`, + }; +} + +/** + * Silent replace: any non-null open request supersedes the current doc with no + * confirmation. Acceptable while Zen content is in-memory only; revisit before + * Zen edits hit real persistence. + */ +export function applyOpenRequest( + current: ZenDoc | null, + req: OpenDocRequest | null, +): ZenDoc | null { + if (!req) return current; + return makeZenDocFromRequest(req); +} diff --git a/src/workspaces/zen/zen.tsx b/src/workspaces/zen/zen.tsx new file mode 100644 index 0000000..a4b6d5f --- /dev/null +++ b/src/workspaces/zen/zen.tsx @@ -0,0 +1,51 @@ +import { useEffect, useRef, useState } from 'react'; +import { EditPad } from '../../components/EditPad'; +import { WorkspaceBarSlot } from '../barSlot'; +import type { WorkspaceProps } from '../workspace'; +import { applyOpenRequest, type ZenDoc } from './doc'; + +export function ZenWorkspace({ openRequestId, openRequest }: WorkspaceProps) { + const [doc, setDoc] = useState(null); + const lastSeenIdRef = useRef(0); + + useEffect(() => { + if (openRequestId === lastSeenIdRef.current) return; + lastSeenIdRef.current = openRequestId; + setDoc((cur) => applyOpenRequest(cur, openRequest)); + }, [openRequestId, openRequest]); + + return ( + <> +
+ {doc ? ( + + setDoc((cur) => (cur ? { ...cur, content: next } : cur)) + } + placeholder="Start writing…" + className="min-h-0 flex-1" + /> + ) : ( +
+ Select a file from the sidebar to open it here. +
+ )} +
+ +
+ {doc ? doc.title : 'Zen · no document open'} +
+ {doc ? ( + + ) : null} +
+ + ); +}