diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 81177c3b3..0821f2942 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -8,7 +8,7 @@ import { disconnectProvider, listProviders, } from './oauth-handler.js'; -import { watcher as watcherCore, workspace } from '@x/core'; +import { watcher as watcherCore, workspace, linkedFolders } from '@x/core'; import { WorkDir } from '@x/core/dist/config/config.js'; import { workspace as workspaceShared } from '@x/shared'; import * as mcpCore from '@x/core/dist/mcp/mcp.js'; @@ -32,6 +32,7 @@ import { ServiceEvent } from '@x/shared/dist/service-events.js'; import type { SessionBusEvent } from '@x/shared/dist/sessions.js'; import { isDurableTurnEvent } from '@x/shared/dist/turns.js'; import type { ISessions, EmitterSessionBus } from '@x/core/dist/runtime/sessions/index.js'; +import { listByWorkDir as listSessionsByWorkDir } from '@x/core/dist/runtime/sessions/by-workdir.js'; import type { ITurnEventBus } from '@x/core/dist/runtime/turns/event-hub.js'; import container from '@x/core/dist/di/container.js'; import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js'; @@ -872,6 +873,10 @@ export function setupIpcHandlers() { // Forward knowledge commit events to renderer for panel refresh versionHistory.onCommit(() => emitKnowledgeCommitEvent()); + // Deletions inside a linked folder go to the OS trash — core can't reach + // Electron's shell, so hand it the implementation. + workspace.registerTrashHandler((absPath) => shell.trashItem(absPath)); + // Relay backend-confirmed credit grants (first-time-action rewards) to all // windows so the UI can update balances and celebrate. subscribeCreditActivations((event) => broadcastToWindows('credits:didActivate', event)); @@ -1040,6 +1045,35 @@ export function setupIpcHandlers() { 'workspace:remove': async (_event, args) => { return workspace.remove(args.path, args.opts); }, + 'workspace:toAbsolute': async (_event, args) => { + return { path: workspace.resolveWorkspacePath(args.path) }; + }, + 'workspace:listFolders': async () => { + return { folders: linkedFolders.listLinkedFolders() }; + }, + 'workspace:addFolder': async (event, args) => { + let chosen = args.path; + if (!chosen) { + const win = BrowserWindow.fromWebContents(event.sender); + const result = await dialog.showOpenDialog(win!, { + title: 'Choose a folder to add as a workspace', + defaultPath: os.homedir(), + buttonLabel: 'Add folder', + properties: ['openDirectory', 'createDirectory'], + }); + if (result.canceled || result.filePaths.length === 0) { + return { folder: null }; + } + chosen = result.filePaths[0]; + } + return { folder: await linkedFolders.addLinkedFolder(chosen!, args.name) }; + }, + 'workspace:renameFolder': async (_event, args) => { + return { folder: await linkedFolders.renameLinkedFolder(args.id, args.name) }; + }, + 'workspace:removeFolder': async (_event, args) => { + return linkedFolders.removeLinkedFolder(args.id); + }, 'gmail:getImportant': async (_event, args) => { return listImportantThreads({ cursor: args.cursor, limit: args.limit }); }, @@ -1177,7 +1211,23 @@ export function setupIpcHandlers() { return runsCore.listRuns(args.cursor); }, 'runs:listByWorkDir': async (_event, args) => { - return runsCore.listRunsByWorkDir(args.dir); + // Chats are sessions now; the legacy runs/*.jsonl logs this used to scan + // are migrated away at boot, so the session index is the only source. + await sessionsIndexReady; + const sessions = container.resolve('sessions').listSessions(); + return listSessionsByWorkDir(args.dir, sessions + // A session the index couldn't load carries empty timestamps, which + // the response schema rejects — one of those would otherwise fail the + // whole call and blank the panel. It has no title or date to show + // anyway, so leave it out. + .filter((s) => !s.error && s.createdAt) + .map((s) => ({ + id: s.sessionId, + ...(s.title ? { title: s.title } : {}), + createdAt: s.createdAt, + modifiedAt: s.updatedAt || s.createdAt, + ...(s.lastAgentId ? { agentId: s.lastAgentId } : {}), + }))); }, 'runs:delete': async (_event, args) => { await runsCore.deleteRun(args.runId); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 7b7042d3e..5ffaccf26 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -31,7 +31,7 @@ import { LiveNotesView } from '@/components/live-notes-view'; import { BgTasksView } from '@/components/bg-tasks-view'; import { AppsView } from '@/components/apps/apps-view'; import { EmailView } from '@/components/email-view'; -import { WorkspaceView } from '@/components/workspace-view'; +import { WorkspaceView, WORKSPACE_CHATS_CHANGED } from '@/components/workspace-view'; import { CodingRunBlock } from '@/components/coding-run'; import { SubAgentBlock } from '@/components/sub-agent-block'; import { KnowledgeView, type KnowledgeViewMode } from '@/components/knowledge-view'; @@ -143,8 +143,11 @@ import { useTheme } from '@/contexts/theme-context' import { TokenUsageMenu } from '@/components/token-usage-menu' type DirEntry = z.infer +type LinkedFolder = z.infer type RunEventType = z.infer +const LINKED_FOLDER_PREFIX = workspace.LINKED_FOLDER_PREFIX + interface TreeNode extends DirEntry { children?: TreeNode[] loaded?: boolean @@ -630,6 +633,32 @@ function flattenMeetingsTree(nodes: TreeNode[]): TreeNode[] { }) } +/** + * Show linked folders (workspaces that live outside WorkDir) alongside the real + * subfolders of knowledge/Workspace. Their children are loaded on demand by the + * workspace view — a linked folder can be an entire repo, so the recursive walk + * that builds this tree deliberately stops at the root. + */ +function graftLinkedFolders(nodes: TreeNode[], folders: LinkedFolder[]): TreeNode[] { + if (folders.length === 0) return nodes + const linkedNodes: TreeNode[] = folders.map((folder) => ({ + name: folder.name, + path: `${LINKED_FOLDER_PREFIX}/${folder.id}`, + kind: 'dir' as const, + children: [], + loaded: false, + })) + const workspaceNode = nodes.find((n) => n.path === WORKSPACE_ROOT) + if (!workspaceNode) { + // knowledge/Workspace doesn't exist on disk yet — the linked folders are + // still workspaces, so surface the parent for them to hang off. + return [...nodes, { name: 'Workspace', path: WORKSPACE_ROOT, kind: 'dir', children: linkedNodes, loaded: true }] + } + return nodes.map((n) => + n === workspaceNode ? { ...n, children: [...(n.children ?? []), ...linkedNodes] } : n, + ) +} + /** Extract YYYY-MM-DD from filenames like "meeting-2026-03-17T05-01-47.md" */ function extractDateFromFilename(name: string): string | null { const match = name.match(/(\d{4}-\d{2}-\d{2})/) @@ -1857,6 +1886,9 @@ function App() { path: `config/workdir-${runId}.json`, data: JSON.stringify(value ? { path: value } : {}, null, 2), }) + // Nothing watches config/, so tell an open workspace view itself — this + // chat just joined (or left) one of its folders. + window.dispatchEvent(new Event(WORKSPACE_CHATS_CHANGED)) } catch (err) { console.error('Failed to persist work directory for run', runId, err) } @@ -2282,10 +2314,10 @@ function App() { } }, [runId, processingRunIds]) - // Load directory tree (knowledge + bases) + // Load directory tree (knowledge + bases + linked folders) const loadDirectory = useCallback(async () => { try { - const [knowledgeResult, basesResult] = await Promise.all([ + const [knowledgeResult, basesResult, foldersResult] = await Promise.all([ window.ipc.invoke('workspace:readdir', { path: 'knowledge', opts: { recursive: true, includeHidden: false, includeStats: true } @@ -2294,8 +2326,12 @@ function App() { path: 'bases', opts: { recursive: false, includeHidden: false, includeStats: true } }).catch(() => [] as DirEntry[]), + window.ipc.invoke('workspace:listFolders', null).catch(() => ({ folders: [] })), ]) - const knowledgeTree = flattenMeetingsTree(buildTree(knowledgeResult)) + const knowledgeTree = graftLinkedFolders( + flattenMeetingsTree(buildTree(knowledgeResult)), + foldersResult.folders, + ) const basesChildren: TreeNode[] = (basesResult as DirEntry[]) .filter((e) => e.name.endsWith('.base')) .map((e) => ({ ...e, kind: 'file' as const })) @@ -5952,14 +5988,27 @@ function App() { } }, copyPath: (path: string) => { - const fullPath = workspaceRoot ? `${workspaceRoot}/${path}` : path - navigator.clipboard.writeText(fullPath).catch(() => { - const textarea = document.createElement('textarea') - textarea.value = fullPath - document.body.appendChild(textarea) - textarea.select() - document.execCommand('copy') - document.body.removeChild(textarea) + // Linked folders live outside WorkDir, so ask the main process where the + // path actually points rather than assuming the workspace root. + const resolve = async (): Promise => { + if (path.startsWith(`${LINKED_FOLDER_PREFIX}/`)) { + try { + return (await window.ipc.invoke('workspace:toAbsolute', { path })).path + } catch { + return path + } + } + return workspaceRoot ? `${workspaceRoot}/${path}` : path + } + void resolve().then((fullPath) => { + navigator.clipboard.writeText(fullPath).catch(() => { + const textarea = document.createElement('textarea') + textarea.value = fullPath + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + document.body.removeChild(textarea) + }) }) }, revealInFileManager: (path: string, isDir: boolean) => { @@ -6983,6 +7032,7 @@ function App() { onNavigate={(path) => { void navigateToView({ type: 'workspace', path: path === WORKSPACE_ROOT ? undefined : path }) }} onOpenNote={(path) => navigateToFile(path)} onCreateWorkspace={async (name) => { await knowledgeActions.createWorkspace(name) }} + onWorkspacesChanged={async () => { setTree(await loadDirectory()) }} onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })} /> diff --git a/apps/x/apps/renderer/src/components/workspace-view.tsx b/apps/x/apps/renderer/src/components/workspace-view.tsx index 71b8803c0..88152bf44 100644 --- a/apps/x/apps/renderer/src/components/workspace-view.tsx +++ b/apps/x/apps/renderer/src/components/workspace-view.tsx @@ -1,5 +1,8 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { + ArrowDown, + ArrowUp, + ArrowUpDown, ChevronRight, Copy, ExternalLink, @@ -8,13 +11,18 @@ import { Folder as FolderIcon, FolderOpen, FolderPlus, + FolderSymlink, + Link2Off, Loader2, MessageSquare, Pencil, Plus, + RefreshCw, Trash2, UploadCloud, } from 'lucide-react' +import type { z } from 'zod' +import { workspace } from '@x/shared' import { Button } from '@/components/ui/button' import { @@ -44,12 +52,73 @@ import { toast } from '@/lib/toast' import { cn } from '@/lib/utils' const WORKSPACE_ROOT = 'knowledge/Workspace' +const LINKED_PREFIX = workspace.LINKED_FOLDER_PREFIX + +/** + * Fired by the app after a chat's work-directory sidecar is written, so an open + * workspace can pick the chat up. That sidecar (`config/workdir-.json`) + * lives outside the workspace watcher's roots and doesn't touch the session + * index, so this is the only signal for "an existing chat joined/left a folder". + */ +export const WORKSPACE_CHATS_CHANGED = 'workspace-chats:changed' + +type LinkedFolder = z.infer interface TreeNode { path: string name: string kind: 'file' | 'dir' children?: TreeNode[] + stat?: { size: number; mtimeMs: number } +} + +type SortKey = 'name' | 'modified' +type SortState = { key: SortKey; dir: 'asc' | 'desc' } +const SORT_STORAGE_KEY = 'workspace:sort' + +function readStoredSort(): SortState { + try { + const raw = localStorage.getItem(SORT_STORAGE_KEY) + if (raw) { + const parsed = JSON.parse(raw) as SortState + if ((parsed.key === 'name' || parsed.key === 'modified') && (parsed.dir === 'asc' || parsed.dir === 'desc')) { + return parsed + } + } + } catch { + // ignore unreadable/legacy values + } + return { key: 'name', dir: 'asc' } +} + +/** "just now" / "6h ago" / "Yesterday" / "3d ago" / "Mar 14" */ +function formatModified(mtimeMs: number | undefined): string { + if (!mtimeMs) return '—' + const diffMs = Math.max(0, Date.now() - mtimeMs) + const min = Math.floor(diffMs / 60000) + if (min < 1) return 'just now' + if (min < 60) return `${min}m ago` + const hr = Math.floor(min / 60) + if (hr < 24) return `${hr}h ago` + const day = Math.floor(hr / 24) + if (day === 1) return 'Yesterday' + if (day < 7) return `${day}d ago` + const d = new Date(mtimeMs) + const sameYear = d.getFullYear() === new Date().getFullYear() + return d.toLocaleDateString([], sameYear + ? { month: 'short', day: 'numeric' } + : { month: 'short', day: 'numeric', year: 'numeric' }) +} + +/** `@folder//sub/path` → the folder id and the folder-relative remainder. */ +function parseLinkedPath(path: string): { id: string; sub: string } | null { + if (!path.startsWith(`${LINKED_PREFIX}/`)) return null + const rest = path.slice(LINKED_PREFIX.length + 1) + if (!rest) return null + const slash = rest.indexOf('/') + return slash === -1 + ? { id: rest, sub: '' } + : { id: rest.slice(0, slash), sub: rest.slice(slash + 1) } } type WorkspaceActions = { @@ -62,6 +131,11 @@ type WorkspaceActions = { onOpenInNewTab?: (path: string) => void } +function SortArrow({ active, dir }: { active: boolean; dir: 'asc' | 'desc' }) { + if (!active) return + return dir === 'asc' ? : +} + function GoogleDriveIcon({ className }: { className?: string }) { return ( void onOpenNote: (path: string) => void onCreateWorkspace: (name: string) => Promise + // A linked folder was added, renamed or unlinked — reload the shared tree. + onWorkspacesChanged: () => Promise | void // Opens a previous chat (run) whose work directory is set to this workspace. onOpenRun: (runId: string) => void } @@ -132,6 +208,20 @@ function findNode(nodes: TreeNode[] | undefined, path: string): TreeNode | null return null } +/** + * Newest mtime anywhere under a node. A folder's own mtime only moves when its + * direct children change, so for a workspace row — where "last updated" should + * mean "someone touched something in here" — walk what the tree already holds. + */ +function deepMtime(node: TreeNode): number { + let newest = node.stat?.mtimeMs ?? 0 + for (const child of node.children ?? []) { + const childMtime = deepMtime(child) + if (childMtime > newest) newest = childMtime + } + return newest +} + function countChildren(node: TreeNode | null): number { if (!node || node.kind !== 'dir' || !node.children) return 0 return node.children.length @@ -162,9 +252,20 @@ function readFileAsBase64(file: File): Promise { }) } -export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNote, onCreateWorkspace, onOpenRun }: WorkspaceViewProps) { +export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNote, onCreateWorkspace, onWorkspacesChanged, onOpenRun }: WorkspaceViewProps) { const currentPath = initialPath || WORKSPACE_ROOT const [addOpen, setAddOpen] = useState(false) + const [linkedFolders, setLinkedFolders] = useState([]) + // Contents of the linked folder currently being browsed. Linked folders sit + // outside the workspace watcher, so this is fetched (and refreshed) here + // rather than read off the shared tree. + const [linkedEntries, setLinkedEntries] = useState([]) + const [linkedLoading, setLinkedLoading] = useState(false) + const [linkedError, setLinkedError] = useState(null) + // mtime of each linked folder's root, keyed by folder id — the registry + // itself has no timestamps, so these are stat'd separately. + const [linkedMtimes, setLinkedMtimes] = useState>({}) + const [sort, setSort] = useState(readStoredSort) const [chatsOpen, setChatsOpen] = useState(false) const [chats, setChats] = useState([]) const [chatsLoading, setChatsLoading] = useState(false) @@ -182,19 +283,79 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo const isRoot = currentPath === WORKSPACE_ROOT const fileManagerName = getFileManagerName() + const linkedRef = useMemo(() => parseLinkedPath(currentPath), [currentPath]) + const isLinked = linkedRef !== null + const linkedById = useMemo( + () => new Map(linkedFolders.map((f) => [f.id, f])), + [linkedFolders], + ) + const currentFolder = linkedRef ? linkedById.get(linkedRef.id) ?? null : null + const currentNode = useMemo(() => findNode(tree, currentPath), [tree, currentPath]) const items = useMemo(() => { - const children = currentNode?.children ?? [] - const filtered = isRoot ? children.filter((c) => c.kind === 'dir') : children + const children = isLinked ? linkedEntries : currentNode?.children ?? [] + let filtered = isRoot ? children.filter((c) => c.kind === 'dir') : children + if (isRoot) { + // Linked folders come from this view's own state, not the shared tree — + // nothing watches the registry, so the tree can lag behind an add/remove. + // Their time is the folder's own mtime: their contents aren't walked + // (a linked folder can be an entire repo), so there's nothing deeper to read. + filtered = [ + ...filtered + .filter((c) => !c.path.startsWith(`${LINKED_PREFIX}/`)) + .map((c) => ({ ...c, stat: { size: c.stat?.size ?? 0, mtimeMs: deepMtime(c) } })), + ...linkedFolders.map((f) => ({ + name: f.name, + path: `${LINKED_PREFIX}/${f.id}`, + kind: 'dir' as const, + stat: linkedMtimes[f.id] ? { size: 0, mtimeMs: linkedMtimes[f.id]! } : undefined, + })), + ] + } + const flip = sort.dir === 'asc' ? 1 : -1 return [...filtered].sort((a, b) => { - if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1 - return a.name.localeCompare(b.name) + // Sorting by time means most-recent-first across the whole folder; + // grouping directories ahead of files only makes sense by name. + if (sort.key === 'name' && a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1 + if (sort.key === 'modified') { + const diff = (a.stat?.mtimeMs ?? 0) - (b.stat?.mtimeMs ?? 0) + if (diff !== 0) return diff * flip + return a.name.localeCompare(b.name) + } + return a.name.localeCompare(b.name) * flip + }) + }, [currentNode, isRoot, isLinked, linkedEntries, linkedFolders, linkedMtimes, sort]) + + const toggleSort = useCallback((key: SortKey) => { + setSort((prev) => { + // First click on a column picks its natural direction: A→Z for names, + // newest first for times. + const next: SortState = prev.key === key + ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } + : { key, dir: key === 'modified' ? 'desc' : 'asc' } + try { + localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(next)) + } catch { + // non-fatal: the sort just won't persist + } + return next }) - }, [currentNode, isRoot]) + }, []) const breadcrumbs = useMemo(() => { if (isRoot) return [] as { path: string; name: string }[] + if (linkedRef) { + const root = `${LINKED_PREFIX}/${linkedRef.id}` + let acc = root + return [ + { path: root, name: currentFolder?.name ?? 'Folder' }, + ...linkedRef.sub.split('/').filter(Boolean).map((seg) => { + acc = `${acc}/${seg}` + return { path: acc, name: seg } + }), + ] + } const rel = currentPath.slice(WORKSPACE_ROOT.length + 1) const parts = rel.split('/').filter(Boolean) let acc = WORKSPACE_ROOT @@ -202,7 +363,64 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo acc = `${acc}/${seg}` return { path: acc, name: seg } }) - }, [currentPath, isRoot]) + }, [currentPath, isRoot, linkedRef, currentFolder]) + + const loadLinkedFolders = useCallback(async () => { + try { + const { folders } = await window.ipc.invoke('workspace:listFolders', null) + setLinkedFolders(folders) + // A folder that has been moved or unmounted simply has no time to show. + const stats = await Promise.all(folders.map(async (f) => { + try { + const s = await window.ipc.invoke('workspace:stat', { path: `${LINKED_PREFIX}/${f.id}` }) + return [f.id, s.mtimeMs] as const + } catch { + return null + } + })) + setLinkedMtimes(Object.fromEntries(stats.filter((s) => s !== null))) + } catch (err) { + console.error('Failed to load linked folders:', err) + } + }, []) + + useEffect(() => { + void loadLinkedFolders() + }, [loadLinkedFolders]) + + // Read the current linked directory. Nothing watches folders outside + // WorkDir (a linked repo's node_modules would blow the fd limit), so this + // re-runs on navigation, on window focus, and after our own mutations. + const refreshLinkedEntries = useCallback(async () => { + if (!isLinked) return + setLinkedLoading(true) + try { + const entries = await window.ipc.invoke('workspace:readdir', { + path: currentPath, + opts: { includeHidden: false, includeStats: true }, + }) + setLinkedEntries(entries.map((e) => ({ path: e.path, name: e.name, kind: e.kind, stat: e.stat }))) + setLinkedError(null) + } catch (err) { + setLinkedEntries([]) + setLinkedError(err instanceof Error ? err.message : 'Failed to read this folder') + } finally { + setLinkedLoading(false) + } + }, [currentPath, isLinked]) + + useEffect(() => { + setLinkedEntries([]) + setLinkedError(null) + void refreshLinkedEntries() + }, [refreshLinkedEntries]) + + useEffect(() => { + if (!isLinked) return + const onFocus = () => void refreshLinkedEntries() + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }, [isLinked, refreshLinkedEntries]) // Load the chats whose work directory is this workspace folder (or nested // inside it). The work directory is stored as an absolute path per run, so @@ -214,8 +432,7 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo } setChatsLoading(true) try { - const { root } = await window.ipc.invoke('workspace:getRoot', null) - const abs = `${root.replace(/\/$/, '')}/${currentPath}` + const { path: abs } = await window.ipc.invoke('workspace:toAbsolute', { path: currentPath }) const { runs } = await window.ipc.invoke('runs:listByWorkDir', { dir: abs }) setChats(runs.map((r) => ({ id: r.id, title: r.title, createdAt: r.createdAt, modifiedAt: r.modifiedAt }))) } catch (err) { @@ -230,6 +447,35 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo void loadChats() }, [loadChats]) + // Keep the list live. The session index changes whenever a chat is created, + // retitled or deleted; WORKSPACE_CHATS_CHANGED covers an existing chat having + // its work directory (re)pointed. Both can fire in bursts — a turn advancing + // republishes the index entry — so coalesce into one refetch. + useEffect(() => { + if (isRoot) return + let timer: ReturnType | null = null + const schedule = () => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + timer = null + void loadChats() + }, 400) + } + const stopSessions = window.ipc.on('sessions:events', schedule) + window.addEventListener(WORKSPACE_CHATS_CHANGED, schedule) + return () => { + if (timer) clearTimeout(timer) + stopSessions() + window.removeEventListener(WORKSPACE_CHATS_CHANGED, schedule) + } + }, [isRoot, loadChats]) + + // Opening the panel always shows current data, even if every live signal was + // somehow missed while it sat closed. + useEffect(() => { + if (chatsOpen) void loadChats() + }, [chatsOpen, loadChats]) + const handleItemClick = useCallback( (item: TreeNode) => { if (renameTarget) return @@ -253,23 +499,66 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo const trimmed = renameValue.trim() setRenameTarget(null) if (!node || !trimmed || trimmed === node.name || trimmed.includes('/')) return + const linked = parseLinkedPath(renameTarget) + // Renaming a linked folder renames how Rowboat labels it — the folder on + // disk keeps its own name. + if (linked && !linked.sub) { + try { + await window.ipc.invoke('workspace:renameFolder', { id: linked.id, name: trimmed }) + await loadLinkedFolders() + await onWorkspacesChanged() + toast('Renamed', 'success') + } catch (err) { + toast(err instanceof Error ? err.message : 'Failed to rename', 'error') + } + return + } const parent = renameTarget.slice(0, renameTarget.lastIndexOf('/')) try { await window.ipc.invoke('workspace:rename', { from: renameTarget, to: `${parent}/${trimmed}` }) + await refreshLinkedEntries() toast('Renamed', 'success') } catch { toast('Failed to rename', 'error') } - }, [renameTarget, renameValue, items]) + }, [renameTarget, renameValue, items, loadLinkedFolders, onWorkspacesChanged, refreshLinkedEntries]) const handleDelete = useCallback(async (item: TreeNode) => { try { await actions.remove(item.path) + await refreshLinkedEntries() toast('Moved to trash', 'success') + } catch (err) { + toast(err instanceof Error ? err.message : 'Failed to delete', 'error') + } + }, [actions, refreshLinkedEntries]) + + // Unlinking never touches the folder itself — it only stops Rowboat + // showing it as a workspace. + const handleUnlink = useCallback(async (folderId: string) => { + try { + await window.ipc.invoke('workspace:removeFolder', { id: folderId }) + await loadLinkedFolders() + await onWorkspacesChanged() + toast('Folder removed from Rowboat. Nothing on disk was deleted.', 'success') } catch { - toast('Failed to delete', 'error') + toast('Failed to remove folder', 'error') } - }, [actions]) + }, [loadLinkedFolders, onWorkspacesChanged]) + + const handleAddFolder = useCallback(async () => { + try { + const { folder } = await window.ipc.invoke('workspace:addFolder', {}) + if (!folder) return + setAddOpen(false) + await loadLinkedFolders() + await onWorkspacesChanged() + toast(`Added "${folder.name}"`, 'success') + onNavigate(`${LINKED_PREFIX}/${folder.id}`) + } catch (err) { + toast(err instanceof Error ? err.message : 'Failed to add folder', 'error') + } + }, [loadLinkedFolders, onWorkspacesChanged, onNavigate]) const uploadFiles = useCallback(async (files: FileList | File[], preserveStructure = false) => { const list = Array.from(files) @@ -289,13 +578,14 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo }) } toast(list.length === 1 ? 'Added' : `${list.length} items added`, 'success') + await refreshLinkedEntries() } catch (err) { console.error('Failed to add files:', err) toast('Failed to add', 'error') } finally { setUploading(false) } - }, [currentPath]) + }, [currentPath, refreshLinkedEntries]) // Drag-and-drop (only inside a workspace folder, not at the root grid). // stopPropagation keeps the drop from also reaching the copilot's @@ -410,6 +700,17 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo Chats{chats.length ? ` (${chats.length})` : ''} )} + {isLinked && ( + + )}
{items.length === 0 ? (
- -
- {isRoot - ? 'No workspaces yet. Create one to get started.' - : 'This folder is empty. Drag files in or use New note / New folder.'} -
- {isRoot && ( - + {linkedLoading ? ( + + ) : ( + <> + +
+ {linkedError + ? linkedError + : isRoot + ? 'No workspaces yet. Create one, or add a folder you already have.' + : 'This folder is empty. Drag files in or use New note / New folder.'} +
+ {isLinked && currentFolder && !linkedError && ( +
+ {currentFolder.path} +
+ )} + {isRoot && ( +
+ + +
+ )} + )}
) : ( -
+
+ {/* Column headers double as the sort control. */} +
+ + {isRoot ? 'Location' : 'Kind'} + +
{items.map((item) => { const childCount = item.kind === 'dir' ? countChildren(item) : 0 - const Icon = item.kind === 'dir' ? FolderIcon : FileIcon + // A linked folder's root row: it points somewhere outside WorkDir, + // and its item count is only known once you open it. + const linkedItem = parseLinkedPath(item.path) + const linkedRoot = linkedItem && !linkedItem.sub ? linkedById.get(linkedItem.id) ?? null : null + const Icon = linkedRoot ? FolderSymlink : item.kind === 'dir' ? FolderIcon : FileIcon const isRenaming = renameTarget === item.path + const detail = linkedRoot + ? linkedRoot.path + : item.kind === 'file' + ? fileExtensionLabel(item.name) + // Children of a linked folder are loaded one level at a time, + // so a subfolder's own count isn't known yet. + : isLinked + ? 'Folder' + : isRoot + ? 'In Rowboat' + : `${childCount} ${childCount === 1 ? 'item' : 'items'}` const card = ( ) const isDir = item.kind === 'dir' @@ -583,10 +954,17 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo Rename - void handleDelete(item)}> - - Delete - + {linkedRoot ? ( + void handleUnlink(linkedRoot.id)}> + + Remove from Rowboat + + ) : ( + void handleDelete(item)}> + + Delete + + )} ) @@ -658,7 +1036,7 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo New workspace - Workspaces are top-level folders inside knowledge/Workspace. + A new workspace is a folder inside knowledge/Workspace.
@@ -678,6 +1056,24 @@ export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNo /> {error &&

{error}

}
+
+
+ or +
+
+