diff --git a/apps/opensidian/package.json b/apps/opensidian/package.json index 765306900a..39945b5eb9 100644 --- a/apps/opensidian/package.json +++ b/apps/opensidian/package.json @@ -24,7 +24,7 @@ "@sveltejs/vite-plugin-svelte": "catalog:", "@tailwindcss/vite": "catalog:", "bun-types": "catalog:", - "lucide-svelte": "^0.577.0", + "@lucide/svelte": "catalog:", "svelte": "catalog:", "svelte-check": "catalog:", "svelte-sonner": "catalog:", diff --git a/apps/opensidian/src/lib/components/CommandPalette.svelte b/apps/opensidian/src/lib/components/CommandPalette.svelte index 8f9454e3df..5aee3139b2 100644 --- a/apps/opensidian/src/lib/components/CommandPalette.svelte +++ b/apps/opensidian/src/lib/components/CommandPalette.svelte @@ -1,6 +1,9 @@ - function handleKeydown(e: KeyboardEvent) { + { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); open = !open; } - } - + }} +/> - - - - - - No files found. - - {#each filteredFiles as file (file.id)} - handleSelect(file.id)}> - {@const Icon = getFileIcon(file.name)} - - {file.name} - {#if file.parentDir} - - {file.parentDir} - - {/if} - - {/each} - - - +/> diff --git a/apps/opensidian/src/lib/components/CreateDialog.svelte b/apps/opensidian/src/lib/components/CreateDialog.svelte deleted file mode 100644 index d0f3550cf6..0000000000 --- a/apps/opensidian/src/lib/components/CreateDialog.svelte +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - {title} - - Enter a name for the new {mode}. - - -
- - Name - - - - - - -
-
-
diff --git a/apps/opensidian/src/lib/components/FileTree.svelte b/apps/opensidian/src/lib/components/FileTree.svelte index 3dad3f37e5..ccd8cba79e 100644 --- a/apps/opensidian/src/lib/components/FileTree.svelte +++ b/apps/opensidian/src/lib/components/FileTree.svelte @@ -3,7 +3,11 @@ import * as Empty from '@epicenter/ui/empty'; import * as TreeView from '@epicenter/ui/tree-view'; import { fsState } from '$lib/fs/fs-state.svelte'; + import DeleteConfirmation from './DeleteConfirmation.svelte'; import FileTreeItem from './FileTreeItem.svelte'; + import InlineNameInput from './InlineNameInput.svelte'; + + let deleteDialogOpen = $state(false); /** * Flat list of visible item IDs in visual order. @@ -26,7 +30,15 @@ return ids; }); + /** Whether an inline create/rename is active (suppresses tree keyboard shortcuts). */ + const isEditing = $derived( + fsState.inlineCreate !== null || fsState.renamingId !== null, + ); + function handleKeydown(e: KeyboardEvent) { + // Don't intercept keys while inline editing is active + if (isEditing) return; + const current = fsState.focusedId; const currentIndex = current ? visibleIds.indexOf(current) : -1; @@ -98,13 +110,34 @@ fsState.actions.focus(visibleIds.at(-1) ?? null); break; } + // ── Inline editing shortcuts ────────────────────────────── + case 'n': + case 'N': { + e.preventDefault(); + fsState.actions.startCreate(e.shiftKey ? 'folder' : 'file'); + break; + } + case 'F2': { + e.preventDefault(); + if (current) fsState.actions.startRename(current); + break; + } + case 'Delete': + case 'Backspace': { + e.preventDefault(); + if (!current) break; + // Select the focused item so DeleteConfirmation reads the right target + fsState.actions.selectFile(current); + deleteDialogOpen = true; + break; + } default: return; // don't prevent default for unhandled keys } } -{#if fsState.rootChildIds.length === 0} +{#if fsState.rootChildIds.length === 0 && !fsState.inlineCreate} No files yet @@ -119,5 +152,14 @@ {#each fsState.rootChildIds as childId (childId)} {/each} + {#if fsState.inlineCreate?.parentId === null} + + {/if} {/if} + + diff --git a/apps/opensidian/src/lib/components/FileTreeItem.svelte b/apps/opensidian/src/lib/components/FileTreeItem.svelte index 6f2f630490..44e363c806 100644 --- a/apps/opensidian/src/lib/components/FileTreeItem.svelte +++ b/apps/opensidian/src/lib/components/FileTreeItem.svelte @@ -4,10 +4,9 @@ import * as TreeView from '@epicenter/ui/tree-view'; import { getFileIcon } from '$lib/fs/file-icons'; import { fsState } from '$lib/fs/fs-state.svelte'; - import CreateDialog from './CreateDialog.svelte'; import DeleteConfirmation from './DeleteConfirmation.svelte'; import FileTreeItem from './FileTreeItem.svelte'; - import RenameDialog from './RenameDialog.svelte'; + import InlineNameInput from './InlineNameInput.svelte'; let { id }: { id: FileId } = $props(); @@ -17,34 +16,26 @@ const isSelected = $derived(fsState.activeFileId === id); const children = $derived(isFolder ? fsState.getChildIds(id) : []); const isFocused = $derived(fsState.focusedId === id); + const isRenaming = $derived(fsState.renamingId === id); + const showInlineCreate = $derived(fsState.inlineCreate?.parentId === id); - let createDialogOpen = $state(false); - let createDialogMode = $state<'file' | 'folder'>('file'); - let renameDialogOpen = $state(false); let deleteDialogOpen = $state(false); - - function selectAndOpenCreate(mode: 'file' | 'folder') { - fsState.actions.selectFile(id); - createDialogMode = mode; - createDialogOpen = true; - } - - function selectAndOpenRename() { - fsState.actions.selectFile(id); - renameDialogOpen = true; - } - - function selectAndOpenDelete() { - fsState.actions.selectFile(id); - deleteDialogOpen = true; - } {#if row} {#snippet child({ props })} - {#if isFolder} + {#if isFolder && isRenaming} +
+ +
+ {:else if isFolder}
{/each} + {#if showInlineCreate} + + {/if}
+ {:else if isRenaming} +
+ +
{:else} {#if isFolder} - selectAndOpenCreate('file')}> + { + fsState.actions.focus(id); + fsState.expandedIds.add(id); + fsState.actions.startCreate('file'); + }}> New File + N - selectAndOpenCreate('folder')}> + { + fsState.actions.focus(id); + fsState.expandedIds.add(id); + fsState.actions.startCreate('folder'); + }}> New Folder + ⇧N {/if} - Rename - + fsState.actions.startRename(id)}> + Rename + F2 + + { + fsState.actions.selectFile(id); + deleteDialogOpen = true; + }} + > Delete +
- - {/if} diff --git a/apps/opensidian/src/lib/components/InlineNameInput.svelte b/apps/opensidian/src/lib/components/InlineNameInput.svelte new file mode 100644 index 0000000000..956f626ab5 --- /dev/null +++ b/apps/opensidian/src/lib/components/InlineNameInput.svelte @@ -0,0 +1,67 @@ + + +
+ {#if icon === 'folder'} + + {:else} + + {/if} + { + if (e.key === 'Enter') { + e.preventDefault(); + confirm(); + } else if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + e.stopPropagation(); + }} + onblur={confirm} + > +
diff --git a/apps/opensidian/src/lib/components/RenameDialog.svelte b/apps/opensidian/src/lib/components/RenameDialog.svelte deleted file mode 100644 index 81325b8842..0000000000 --- a/apps/opensidian/src/lib/components/RenameDialog.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - Rename - Enter a new name. - -
- - Name - - - - - - -
-
-
diff --git a/apps/opensidian/src/lib/components/TabBar.svelte b/apps/opensidian/src/lib/components/TabBar.svelte index 6a438ed2ee..28d6172e0b 100644 --- a/apps/opensidian/src/lib/components/TabBar.svelte +++ b/apps/opensidian/src/lib/components/TabBar.svelte @@ -1,7 +1,7 @@ - - - - - No commands found. - - {#each quickActions as action (action.id)} - { - open = false; - action.execute(); - }} - > - -
- {action.label} - {action.description} -
-
- {/each} -
-
-
diff --git a/apps/tab-manager/src/lib/components/command-palette/CommandPalette.svelte b/apps/tab-manager/src/lib/components/command-palette/CommandPalette.svelte new file mode 100644 index 0000000000..38fdc4560b --- /dev/null +++ b/apps/tab-manager/src/lib/components/command-palette/CommandPalette.svelte @@ -0,0 +1,8 @@ + + + diff --git a/apps/tab-manager/src/lib/components/command-palette/index.ts b/apps/tab-manager/src/lib/components/command-palette/index.ts new file mode 100644 index 0000000000..a4dac30afc --- /dev/null +++ b/apps/tab-manager/src/lib/components/command-palette/index.ts @@ -0,0 +1 @@ +export { default as CommandPalette } from './CommandPalette.svelte'; \ No newline at end of file diff --git a/apps/tab-manager/src/lib/components/command-palette/items.ts b/apps/tab-manager/src/lib/components/command-palette/items.ts new file mode 100644 index 0000000000..3a83d0581c --- /dev/null +++ b/apps/tab-manager/src/lib/components/command-palette/items.ts @@ -0,0 +1,216 @@ +/** + * Command palette items for the tab manager. + * + * Each item has a label, description, icon, and `onSelect` handler. + * Some items open a confirmation dialog before executing—they manage + * this internally so the confirmation message can include runtime context + * (e.g. "Found 5 duplicates across 3 URLs"). + * + * @example + * ```typescript + * import { items } from './items'; + * + * for (const item of items) { + * console.log(item.label, item.description); + * } + * ``` + */ + +import { confirmationDialog } from '@epicenter/ui/confirmation-dialog'; +import type { CommandPaletteItem } from '@epicenter/ui/command-palette'; +import ArchiveIcon from '@lucide/svelte/icons/archive'; +import ArrowDownAZIcon from '@lucide/svelte/icons/arrow-down-a-z'; +import CopyMinusIcon from '@lucide/svelte/icons/copy-minus'; +import GlobeIcon from '@lucide/svelte/icons/globe'; +import GroupIcon from '@lucide/svelte/icons/group'; +import { Ok, tryAsync } from 'wellcrafted/result'; +import { browserState } from '$lib/state/browser-state.svelte'; +import { savedTabState } from '$lib/state/saved-tab-state.svelte'; +import { findDuplicateGroups, groupTabsByDomain } from '$lib/utils/tab-helpers'; +import type { TabCompositeId } from '$lib/workspace'; +import { parseTabId } from '$lib/workspace'; + + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Batch-resolve composite tab IDs to native Chrome tab IDs. + */ +function compositeToNativeIds(compositeIds: TabCompositeId[]): number[] { + return compositeIds + .map((id) => parseTabId(id)?.tabId) + .filter((id) => id !== undefined); +} + +/** + * Get all tabs across all windows as a flat array. + */ +function getAllTabs() { + return browserState.windows.flatMap((w) => browserState.tabsByWindow(w.id)); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Actions +// ───────────────────────────────────────────────────────────────────────────── + +/** + * All command palette items for the tab manager. + * + * Ordered by expected frequency of use. + */ +export const items: CommandPaletteItem[] = [ + { + id: 'dedup', + label: 'Remove Duplicates', + description: 'Close duplicate tabs with the same URL', + icon: CopyMinusIcon, + keywords: ['dedup', 'duplicate', 'remove', 'close', 'clean'], + group: 'Quick Actions', + onSelect() { + const dupes = findDuplicateGroups(getAllTabs()); + if (dupes.size === 0) return; + + const totalDuplicates = [...dupes.values()].reduce( + (sum, group) => sum + group.length - 1, + 0, + ); + + const toClose = [...dupes.values()].flatMap((group) => + group.slice(1).map((t) => t.id), + ); + + confirmationDialog.open({ + title: 'Remove Duplicate Tabs', + description: `Found ${totalDuplicates} duplicate tab${totalDuplicates === 1 ? '' : 's'} across ${dupes.size} URL${dupes.size === 1 ? '' : 's'}. Close them?`, + confirm: { text: 'Close Duplicates', variant: 'destructive' }, + async onConfirm() { + const nativeIds = compositeToNativeIds(toClose); + await tryAsync({ + try: () => browser.tabs.remove(nativeIds), + catch: () => Ok(undefined), + }); + }, + }); + }, + }, + { + id: 'group-by-domain', + label: 'Group Tabs by Domain', + description: 'Create tab groups based on website domain', + icon: GroupIcon, + keywords: ['group', 'domain', 'organize', 'categorize'], + group: 'Quick Actions', + async onSelect() { + const allTabs = getAllTabs(); + const domains = groupTabsByDomain(allTabs); + + const groupOps = [...domains.entries()] + .filter(([, tabs]) => tabs.length >= 2) + .map(([domain, tabs]) => { + const nativeIds = compositeToNativeIds( + tabs.map((t) => t.id), + ); + return nativeIds.length >= 2 ? { domain, nativeIds } : null; + }) + .filter((op) => op !== null); + + await Promise.allSettled( + groupOps.map(async ({ domain, nativeIds }) => { + const groupId = await browser.tabs.group({ + tabIds: nativeIds as [number, ...number[]], + }); + await browser.tabGroups.update(groupId, { title: domain }); + }), + ); + }, + }, + { + id: 'sort', + label: 'Sort Tabs by Title', + description: 'Sort tabs alphabetically within each window', + icon: ArrowDownAZIcon, + keywords: ['sort', 'alphabetical', 'order', 'organize'], + group: 'Quick Actions', + async onSelect() { + for (const window of browserState.windows) { + const tabs = browserState.tabsByWindow(window.id); + const sorted = [...tabs].sort((a, b) => + (a.title ?? '').localeCompare(b.title ?? ''), + ); + + for (let i = 0; i < sorted.length; i++) { + const tab = sorted[i]; + if (!tab) continue; + const parsed = parseTabId(tab.id); + if (!parsed) continue; + await tryAsync({ + try: () => browser.tabs.move(parsed.tabId, { index: i }), + catch: () => Ok(undefined), + }); + } + } + }, + }, + { + id: 'close-by-domain', + label: 'Close Tabs by Domain', + description: 'Close all tabs from a specific domain', + icon: GlobeIcon, + keywords: ['close', 'domain', 'website', 'remove'], + group: 'Quick Actions', + onSelect() { + const domains = groupTabsByDomain(getAllTabs()); + if (domains.size === 0) return; + + let topDomain = ''; + let topCount = 0; + for (const [domain, tabs] of domains) { + if (tabs.length > topCount) { + topDomain = domain; + topCount = tabs.length; + } + } + + const tabIds = (domains.get(topDomain) ?? []).map((t) => t.id); + + confirmationDialog.open({ + title: `Close ${topDomain} Tabs`, + description: `Close ${topCount} tab${topCount === 1 ? '' : 's'} from ${topDomain}?`, + confirm: { text: 'Close Tabs', variant: 'destructive' }, + async onConfirm() { + const nativeIds = compositeToNativeIds(tabIds); + await tryAsync({ + try: () => browser.tabs.remove(nativeIds), + catch: () => Ok(undefined), + }); + }, + }); + }, + }, + { + id: 'save-all', + label: 'Save All Tabs', + description: 'Save all open tabs for later and close them', + icon: ArchiveIcon, + keywords: ['save', 'all', 'close', 'stash', 'park'], + group: 'Quick Actions', + onSelect() { + const allTabs = getAllTabs(); + if (allTabs.length === 0) return; + + confirmationDialog.open({ + title: 'Save All Tabs', + description: `Save and close ${allTabs.length} tab${allTabs.length === 1 ? '' : 's'}?`, + confirm: { text: 'Save & Close All', variant: 'destructive' }, + async onConfirm() { + const tabsWithUrls = allTabs.filter((tab) => tab.url); + await Promise.allSettled( + tabsWithUrls.map((tab) => savedTabState.actions.save(tab)), + ); + }, + }); + }, + }, +]; diff --git a/apps/tab-manager/src/lib/quick-actions.ts b/apps/tab-manager/src/lib/quick-actions.ts deleted file mode 100644 index bf7d527cea..0000000000 --- a/apps/tab-manager/src/lib/quick-actions.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Quick action registry for the command palette. - * - * Each action has a label, description, icon, and execute function. - * Dangerous actions show a confirmation dialog before executing. - * Actions read from {@link browserState} and call Chrome APIs. - * - * @example - * ```typescript - * import { quickActions } from '$lib/quick-actions'; - * - * for (const action of quickActions) { - * console.log(action.label, action.description); - * } - * ``` - */ - -import { confirmationDialog } from '@epicenter/ui/confirmation-dialog'; -import CopyMinusIcon from '@lucide/svelte/icons/copy-minus'; -import GroupIcon from '@lucide/svelte/icons/group'; -import type { Component } from 'svelte'; -import { Ok, tryAsync } from 'wellcrafted/result'; -import { browserState } from '$lib/state/browser-state.svelte'; -import { findDuplicateGroups, groupTabsByDomain } from '$lib/utils/tab-helpers'; -import type { TabCompositeId } from '$lib/workspace'; -import { parseTabId } from '$lib/workspace'; - -// ───────────────────────────────────────────────────────────────────────────── -// Types -// ───────────────────────────────────────────────────────────────────────────── - -export type QuickAction = { - id: string; - label: string; - description: string; - icon: Component; - keywords: string[]; - execute: () => Promise | void; - /** When true, execute shows a confirmation dialog before running. */ - dangerous?: boolean; -}; - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Batch-resolve composite tab IDs to native Chrome tab IDs. - */ -function compositeToNativeIds(compositeIds: TabCompositeId[]): number[] { - return compositeIds - .map((id) => parseTabId(id)?.tabId) - .filter((id) => id !== undefined); -} - -/** - * Get all tabs across all windows as a flat array. - */ -function getAllTabs() { - return browserState.windows.flatMap((w) => browserState.tabsByWindow(w.id)); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Actions -// ───────────────────────────────────────────────────────────────────────────── - -const dedupAction: QuickAction = { - id: 'dedup', - label: 'Remove Duplicates', - description: 'Close duplicate tabs with the same URL', - icon: CopyMinusIcon, - keywords: ['dedup', 'duplicate', 'remove', 'close', 'clean'], - dangerous: true, - execute() { - const dupes = findDuplicateGroups(getAllTabs()); - if (dupes.size === 0) return; - - const totalDuplicates = [...dupes.values()].reduce( - (sum, group) => sum + group.length - 1, - 0, - ); - - const toClose = [...dupes.values()].flatMap((group) => - group.slice(1).map((t) => t.id), - ); - - confirmationDialog.open({ - title: 'Remove Duplicate Tabs', - description: `Found ${totalDuplicates} duplicate tab${totalDuplicates === 1 ? '' : 's'} across ${dupes.size} URL${dupes.size === 1 ? '' : 's'}. Close them?`, - confirm: { text: 'Close Duplicates', variant: 'destructive' }, - async onConfirm() { - const nativeIds = compositeToNativeIds(toClose); - await tryAsync({ - try: () => browser.tabs.remove(nativeIds), - catch: () => Ok(undefined), - }); - }, - }); - }, -}; - -const groupByDomainAction: QuickAction = { - id: 'group-by-domain', - label: 'Group Tabs by Domain', - description: 'Create tab groups based on website domain', - icon: GroupIcon, - keywords: ['group', 'domain', 'organize', 'categorize'], - async execute() { - const allTabs = getAllTabs(); - const domains = groupTabsByDomain(allTabs); - - const groupOps = [...domains.entries()] - .filter(([, tabs]) => tabs.length >= 2) - .map(([domain, tabs]) => { - const nativeIds = compositeToNativeIds( - tabs.map((t) => t.id), - ); - return nativeIds.length >= 2 ? { domain, nativeIds } : null; - }) - .filter((op) => op !== null); - - await Promise.allSettled( - groupOps.map(async ({ domain, nativeIds }) => { - const groupId = await browser.tabs.group({ - tabIds: nativeIds as [number, ...number[]], - }); - await browser.tabGroups.update(groupId, { title: domain }); - }), - ); - }, -}; - -/** - * All registered quick actions for the command palette. - * - * Actions are ordered by expected frequency of use. - */ -export const quickActions: QuickAction[] = [dedupAction, groupByDomainAction]; diff --git a/apps/whispering/src/lib/commands.ts b/apps/whispering/src/lib/commands.ts index 5b70bfe736..cc71f0f611 100644 --- a/apps/whispering/src/lib/commands.ts +++ b/apps/whispering/src/lib/commands.ts @@ -35,9 +35,9 @@ export const commands = [ on: ['Pressed', 'Released'], callback: (state?: ShortcutEventState) => { if (state === 'Pressed') { - rpc.commands.startManualRecording(undefined); + rpc.actions.startManualRecording(undefined); } else if (state === 'Released') { - rpc.commands.stopManualRecording(undefined); + rpc.actions.stopManualRecording(undefined); } }, }, @@ -45,55 +45,55 @@ export const commands = [ id: 'toggleManualRecording', title: 'Toggle recording', on: ['Pressed'], - callback: () => rpc.commands.toggleManualRecording(undefined), + callback: () => rpc.actions.toggleManualRecording(undefined), }, { id: 'startManualRecording', title: 'Start recording', on: ['Pressed'], - callback: () => rpc.commands.startManualRecording(undefined), + callback: () => rpc.actions.startManualRecording(undefined), }, { id: 'stopManualRecording', title: 'Stop recording', on: ['Pressed'], - callback: () => rpc.commands.stopManualRecording(undefined), + callback: () => rpc.actions.stopManualRecording(undefined), }, { id: 'cancelManualRecording', title: 'Cancel recording', on: ['Pressed'], - callback: () => rpc.commands.cancelManualRecording(undefined), + callback: () => rpc.actions.cancelManualRecording(undefined), }, { id: 'startVadRecording', title: 'Start voice activated recording', on: ['Pressed'], - callback: () => rpc.commands.startVadRecording(undefined), + callback: () => rpc.actions.startVadRecording(undefined), }, { id: 'stopVadRecording', title: 'Stop voice activated recording', on: ['Pressed'], - callback: () => rpc.commands.stopVadRecording(undefined), + callback: () => rpc.actions.stopVadRecording(undefined), }, { id: 'toggleVadRecording', title: 'Toggle voice activated recording', on: ['Pressed'], - callback: () => rpc.commands.toggleVadRecording(undefined), + callback: () => rpc.actions.toggleVadRecording(undefined), }, { id: 'openTransformationPicker', title: 'Open transformation picker', on: ['Pressed'], - callback: () => rpc.commands.openTransformationPicker(undefined), + callback: () => rpc.actions.openTransformationPicker(undefined), }, { id: 'runTransformationOnClipboard', title: 'Run transformation on clipboard', on: ['Pressed'], - callback: () => rpc.commands.runTransformationOnClipboard(undefined), + callback: () => rpc.actions.runTransformationOnClipboard(undefined), }, ] as const satisfies SatisfiedCommand[]; diff --git a/apps/whispering/src/lib/query/actions.ts b/apps/whispering/src/lib/query/actions.ts index 0229f548d9..23859724b6 100644 --- a/apps/whispering/src/lib/query/actions.ts +++ b/apps/whispering/src/lib/query/actions.ts @@ -325,7 +325,7 @@ const stopVadRecording = defineMutation({ }, }); -export const commands = { +export const actions = { startManualRecording, stopManualRecording, startVadRecording, diff --git a/apps/whispering/src/lib/query/index.ts b/apps/whispering/src/lib/query/index.ts index a723bf8eae..f52dc1aa9d 100644 --- a/apps/whispering/src/lib/query/index.ts +++ b/apps/whispering/src/lib/query/index.ts @@ -1,4 +1,4 @@ -import { commands } from './actions'; +import { actions } from './actions'; import { analytics } from './analytics'; import { db } from './db'; import { delivery } from './delivery'; @@ -20,7 +20,7 @@ export { desktopRpc } from './desktop'; export const rpc = { analytics, text, - commands, + actions, db, download, recorder, diff --git a/apps/whispering/src/routes/(app)/+page.svelte b/apps/whispering/src/routes/(app)/+page.svelte index b6b4653378..d1b1e5c9ef 100644 --- a/apps/whispering/src/routes/(app)/+page.svelte +++ b/apps/whispering/src/routes/(app)/+page.svelte @@ -151,7 +151,7 @@ } if (files.length > 0) { - await rpc.commands.uploadRecordings({ files }); + await rpc.actions.uploadRecordings({ files }); } }, ); @@ -181,12 +181,12 @@ { mode: 'manual' as const, isActive: () => recorderState === 'RECORDING', - stop: () => rpc.commands.stopManualRecording(), + stop: () => rpc.actions.stopManualRecording(), }, { mode: 'vad' as const, isActive: () => vadRecorder.state !== 'IDLE', - stop: () => rpc.commands.stopVadRecording(), + stop: () => rpc.actions.stopVadRecording(), }, ] satisfies { mode: RecordingMode; @@ -345,7 +345,7 @@ maxFileSize={25 * MEGABYTE} onUpload={async (files) => { if (files.length > 0) { - await rpc.commands.uploadRecordings({ files }); + await rpc.actions.uploadRecordings({ files }); } }} onFileRejected={({ file, reason }) => { diff --git a/docs/articles/you-dont-need-global-shortcuts-for-in-app-hotkeys.md b/docs/articles/you-dont-need-global-shortcuts-for-in-app-hotkeys.md new file mode 100644 index 0000000000..44384ba6db --- /dev/null +++ b/docs/articles/you-dont-need-global-shortcuts-for-in-app-hotkeys.md @@ -0,0 +1,46 @@ +# You Don't Need Global Shortcuts for In-App Hotkeys + +If you want keyboard shortcuts inside your Tauri app, the most idiomatic approach is still `window.addEventListener('keydown', ...)`. Same as any web app. Tauri's global shortcut plugin exists for a specific, narrower use case: shortcuts that fire regardless of whether your app is focused, minimized, or even if another application is active. + +Most people reach for `tauri-plugin-global-shortcut` the moment they hear "keyboard shortcuts in Tauri." Don't. You probably just need a regular event listener. + +## Global Shortcuts Are OS-Level Hooks + +When you register a global shortcut, Tauri talks directly to the operating system. macOS, Windows, and Linux each have their own hotkey registration APIs, and the plugin abstracts over all of them. The shortcut fires everywhere—your app could be minimized to the system tray and it still triggers. + +```typescript +import { register } from '@tauri-apps/plugin-global-shortcut'; + +// This fires even when you're in Chrome, VS Code, anywhere +await register('CommandOrControl+Shift+Space', (event) => { + if (event.state === 'Pressed') { + startRecording(); + } +}); +``` + +That's the entire point. It's a system-wide hook. The OS intercepts the key combination before any focused application sees it. + +## Regular Event Listeners Work in Tauri + +Tauri renders your frontend in a webview. The webview handles keyboard events like any browser. So standard DOM APIs work exactly as you'd expect: + +```typescript +// This fires only when your app window is focused +window.addEventListener('keydown', (e) => { + if (e.key === 's' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + saveDocument(); + } +}); +``` + +No plugin needed. No Cargo dependency. No permission configuration. The webview already gives you this for free. + +## When to Use Which + +Reach for global shortcuts when the user isn't looking at your app. Push-to-talk while in a game. Media controls while browsing. Quick capture from any context. Whispering uses a global shortcut for exactly this—you press the hotkey in whatever app you're using and it starts recording. + +Stick with `addEventListener` for everything else. Navigation, editor commands, form shortcuts, panel toggles—anything where the user is already interacting with your window. It's simpler, requires no native plugin, and behaves identically to how you'd do it in a web app. + +The global shortcut plugin also comes with overhead you don't want unless you need it: OS-level registration that can conflict with other apps' shortcuts, platform-specific permission requirements, and cleanup logic to unregister on exit. Regular event listeners have none of these concerns. diff --git a/packages/filesystem/src/extensions/sqlite-index/index.ts b/packages/filesystem/src/extensions/sqlite-index/index.ts index a365f14a33..7343146936 100644 --- a/packages/filesystem/src/extensions/sqlite-index/index.ts +++ b/packages/filesystem/src/extensions/sqlite-index/index.ts @@ -153,7 +153,7 @@ export function createSqliteIndex(options: SqliteIndexOptions = {}) { const client = createClient({ url: ':memory:' }); let db: LibSQLDatabase; let syncTimeout: ReturnType | null = null; - let rebuilding = false; + let pendingIds = new Set(); let unobserve: (() => void) | null = null; // ── Async initialization ────────────────────────────────────── @@ -179,83 +179,228 @@ export function createSqliteIndex(options: SqliteIndexOptions = {}) { await rebuild(); // Observe ongoing table mutations - unobserve = filesTable.observe(() => scheduleSync()); + unobserve = filesTable.observe((changedIds) => scheduleSync(changedIds)); })(); // ── Debounced sync ──────────────────────────────────────────── - function scheduleSync() { + function scheduleSync(changedIds: Set) { + for (const id of changedIds) pendingIds.add(id); if (syncTimeout) clearTimeout(syncTimeout); syncTimeout = setTimeout(() => { syncTimeout = null; - void rebuild(); + const ids = pendingIds; + pendingIds = new Set(); + void syncRows(ids); }, debounceMs); } - // ── Full rebuild ────────────────────────────────────────────── + // ── Full rebuild ────────────────────────────────────────── async function rebuild(): Promise { - if (rebuilding) return; - rebuilding = true; + const rows = filesTable.getAllValid(); + const paths = computePaths(rows); + + // Read content for files (skip folders) + const contentMap = new Map(); + for (const row of rows) { + if (row.type === 'folder') { + contentMap.set(row.id, null); + continue; + } + try { + const handle = await contentDocs.open(row.id); + const text = handle.read(); + contentMap.set(row.id, text || null); + } catch { + contentMap.set(row.id, null); + } + } - try { - const rows = filesTable.getAllValid(); - const paths = computePaths(rows); - - // Read content for files (skip folders) - const contentMap = new Map(); - for (const row of rows) { - if (row.type === 'folder') { - contentMap.set(row.id, null); - continue; - } - try { - const handle = await contentDocs.open(row.id); - const text = handle.read(); - contentMap.set(row.id, text || null); - } catch { - contentMap.set(row.id, null); - } + // Build batch: nuke + reinsert in a single transaction + const statements: InStatement[] = [ + 'DELETE FROM files_fts', + 'DELETE FROM files', + ]; + + for (const row of rows) { + const path = paths.get(row.id) ?? null; + const content = contentMap.get(row.id) ?? null; + + statements.push({ + sql: `INSERT INTO files + (id, name, parent_id, type, path, size, created_at, updated_at, trashed_at, content) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + row.id, + row.name, + row.parentId, + row.type, + path, + row.size, + row.createdAt, + row.updatedAt, + row.trashedAt, + content, + ], + }); + + // Insert into FTS — use empty string for null content + // so the file name is still searchable + statements.push({ + sql: 'INSERT INTO files_fts (file_id, name, content) VALUES (?, ?, ?)', + args: [row.id, row.name, content ?? ''], + }); + } + + // libSQL batch executes all statements in a single transaction + await client.batch(statements, 'write'); + } + + // ── Surgical sync ──────────────────────────────────────── + + async function syncRows(changedIds: Set): Promise { + const statements: InStatement[] = []; + + // Classify changed rows + const folderIds: string[] = []; + const fileIds: string[] = []; + const deletedIds: string[] = []; + + for (const id of changedIds) { + const result = filesTable.get(id); + if (result.status !== 'valid') { + deletedIds.push(id); + } else if (result.row.type === 'folder') { + folderIds.push(id); + } else { + fileIds.push(id); } + } - // Build batch: nuke + reinsert in a single transaction - const statements: InStatement[] = [ - 'DELETE FROM files_fts', - 'DELETE FROM files', - ]; - - for (const row of rows) { - const path = paths.get(row.id) ?? null; - const content = contentMap.get(row.id) ?? null; - - statements.push({ - sql: `INSERT INTO files - (id, name, parent_id, type, path, size, created_at, updated_at, trashed_at, content) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - args: [ - row.id, - row.name, - row.parentId, - row.type, - path, - row.size, - row.createdAt, - row.updatedAt, - row.trashedAt, - content, - ], - }); + // Process deletes + for (const id of deletedIds) { + statements.push({ + sql: 'DELETE FROM files_fts WHERE file_id = ?', + args: [id], + }); + statements.push({ + sql: 'DELETE FROM files WHERE id = ?', + args: [id], + }); + } + + // Process folders first (path cascading must precede file processing) + for (const id of folderIds) { + const result = filesTable.get(id); + if (result.status !== 'valid') continue; + const row = result.row; + const path = computePathForRow(id, filesTable); + + // Query current path from SQLite before mutation + const oldResult = await client.execute({ + sql: 'SELECT path FROM files WHERE id = ?', + args: [id], + }); + const oldPath = oldResult.rows[0]?.path as string | null | undefined; - // Insert into FTS — use empty string for null content - // so the file name is still searchable - statements.push({ - sql: 'INSERT INTO files_fts (file_id, name, content) VALUES (?, ?, ?)', - args: [row.id, row.name, content ?? ''], + // DELETE + INSERT the folder + statements.push({ + sql: 'DELETE FROM files_fts WHERE file_id = ?', + args: [id], + }); + statements.push({ + sql: 'DELETE FROM files WHERE id = ?', + args: [id], + }); + statements.push({ + sql: `INSERT INTO files + (id, name, parent_id, type, path, size, created_at, updated_at, trashed_at, content) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + row.id, + row.name, + row.parentId, + row.type, + path, + row.size, + row.createdAt, + row.updatedAt, + row.trashedAt, + null, + ], + }); + statements.push({ + sql: 'INSERT INTO files_fts (file_id, name, content) VALUES (?, ?, ?)', + args: [row.id, row.name, ''], + }); + + // Cascade: if folder path changed, update all descendant paths + if (oldPath != null && path != null && oldPath !== path) { + const descendants = await client.execute({ + sql: "SELECT id, path FROM files WHERE path LIKE ? || '/%'", + args: [oldPath], }); + + for (const desc of descendants.rows) { + const descId = desc.id as string; + const descOldPath = desc.path as string; + const descNewPath = path + descOldPath.slice(oldPath.length); + statements.push({ + sql: 'UPDATE files SET path = ? WHERE id = ?', + args: [descNewPath, descId], + }); + } + } + } + + // Process files + for (const id of fileIds) { + const result = filesTable.get(id); + if (result.status !== 'valid') continue; + const row = result.row; + const path = computePathForRow(id, filesTable); + + let content: string | null = null; + try { + const handle = await contentDocs.open(row.id); + const text = handle.read(); + content = text || null; + } catch { + content = null; } - // libSQL batch executes all statements in a single transaction + statements.push({ + sql: 'DELETE FROM files_fts WHERE file_id = ?', + args: [id], + }); + statements.push({ + sql: 'DELETE FROM files WHERE id = ?', + args: [id], + }); + statements.push({ + sql: `INSERT INTO files + (id, name, parent_id, type, path, size, created_at, updated_at, trashed_at, content) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + row.id, + row.name, + row.parentId, + row.type, + path, + row.size, + row.createdAt, + row.updatedAt, + row.trashedAt, + content, + ], + }); + statements.push({ + sql: 'INSERT INTO files_fts (file_id, name, content) VALUES (?, ?, ?)', + args: [row.id, row.name, content ?? ''], + }); + } + + if (statements.length > 0) { await client.batch(statements, 'write'); - } finally { - rebuilding = false; } } @@ -372,3 +517,44 @@ function computePaths(rows: FileRow[]): Map { return paths; } + +/** + * Compute a materialized POSIX path for a single row by walking its parentId chain. + * + * Uses `filesTable.get()` for each hop instead of bulk reads. Returns `null` + * only when the target row itself doesn't exist. Cycles and orphans fall back + * to root-level `/{name}`, matching the behavior of {@link computePaths}. + */ +function computePathForRow( + id: string, + filesTable: TableHelper, +): string | null { + const visited = new Set(); + + function walk(currentId: string): string | null { + if (visited.has(currentId)) return null; + visited.add(currentId); + + const result = filesTable.get(currentId); + if (result.status !== 'valid') return null; + + const row = result.row; + + if (row.parentId === null) { + return `/${row.name}`; + } + + // Guard against unreasonably deep trees + if (visited.size > MAX_PATH_DEPTH) return null; + + const parentPath = walk(row.parentId); + if (parentPath === null) { + // Orphan or cycle — treat as root-level + return `/${row.name}`; + } + + return `${parentPath}/${row.name}`; + } + + return walk(id); +} diff --git a/packages/filesystem/src/file-system.ts b/packages/filesystem/src/file-system.ts index a93c3a7ac7..db12009544 100644 --- a/packages/filesystem/src/file-system.ts +++ b/packages/filesystem/src/file-system.ts @@ -170,6 +170,9 @@ export function createYjsFileSystem( async writeFile(path, data, _options?) { const abs = posixResolve(cwd, path); + const textData = + typeof data === 'string' ? data : new TextDecoder().decode(data); + const size = new TextEncoder().encode(textData).byteLength; let id = tree.lookupId(abs); if (id) { @@ -179,17 +182,11 @@ export function createYjsFileSystem( if (!id) { const { parentId, name } = tree.parsePath(abs); - const textData = - typeof data === 'string' ? data : new TextDecoder().decode(data); - const size = new TextEncoder().encode(textData).byteLength; id = tree.create({ name, parentId, type: 'file', size }); } - const textData = - typeof data === 'string' ? data : new TextDecoder().decode(data); const handle = await contentDocuments.open(id); handle.write(textData); - const size = new TextEncoder().encode(textData).byteLength; tree.touch(id, size); }, @@ -198,7 +195,7 @@ export function createYjsFileSystem( const text = typeof data === 'string' ? data : new TextDecoder().decode(data); const id = tree.lookupId(abs); - if (!id) return this.writeFile(abs, data, _options); + if (!id) return this.writeFile(path, data, _options); const row = tree.getRow(id, abs); if (row.type === 'folder') throw FS_ERRORS.EISDIR(abs); diff --git a/packages/ui/src/command-palette/command-palette.svelte b/packages/ui/src/command-palette/command-palette.svelte new file mode 100644 index 0000000000..0cfea72bb9 --- /dev/null +++ b/packages/ui/src/command-palette/command-palette.svelte @@ -0,0 +1,74 @@ + + + + + + {emptyMessage} + {#each grouped as [ group, groupItems ]} + + {#each groupItems as item (item.id)} + { + open = false; + if (item.destructive) { + confirmationDialog.open({ + title: item.label, + description: item.description ?? 'Are you sure?', + confirm: { text: 'Confirm', variant: 'destructive' }, + onConfirm: () => item.onSelect(), + }); + } else { + item.onSelect(); + } + }} + > + {#if item.icon} + {@const Icon = item.icon} + + {/if} +
+ {item.label} + {#if item.description} + + {item.description} + + {/if} +
+
+ {/each} +
+ {/each} +
+
diff --git a/packages/ui/src/command-palette/index.ts b/packages/ui/src/command-palette/index.ts new file mode 100644 index 0000000000..b9b7b4e2fd --- /dev/null +++ b/packages/ui/src/command-palette/index.ts @@ -0,0 +1,79 @@ +import type { Component } from 'svelte'; + +export { default as CommandPalette } from './command-palette.svelte'; + +/** + * A single item that can appear in the command palette. + * + * This is the shared contract between command sources (workspace actions, + * static registries, dynamic search results) and the `CommandPalette` component. + * Anything that produces this shape can feed the palette—no adapters, no wrappers. + * + * Only `id`, `label`, and `onSelect` are required. Every other field is a + * progressive enhancement—omit what you don't need and the component adapts: + * no icon → text-only row, no description → single-line label, no keywords → + * search matches against the label alone. + * + * @example + * ```typescript + * const items: CommandPaletteItem[] = [ + * { + * id: 'dedup', + * label: 'Remove Duplicates', + * description: 'Close duplicate tabs with the same URL', + * icon: CopyMinusIcon, + * keywords: ['dedup', 'duplicate', 'clean'], + * group: 'Quick Actions', + * destructive: true, + * onSelect: () => removeDuplicates(), + * }, + * ]; + * ``` + */ +export type CommandPaletteItem = { + /** Stable identifier used as the Svelte `{#each}` key. Must be unique within the items array. */ + id: string; + /** Primary display text shown for this command. Also used as the base search target. */ + label: string; + /** + * Secondary text rendered below the label in a smaller, muted style. + * + * When the item is `destructive`, this doubles as the confirmation dialog + * description (falls back to "Are you sure?" when omitted). + */ + description?: string; + /** + * Svelte component rendered as a 16×16 icon to the left of the label. + * + * Typically a Lucide icon import. Omit when icons aren't available + * (e.g. programmatically generated items). + */ + icon?: Component; + /** + * Extra search tokens beyond the label. The palette builds its search + * value as `[label, ...keywords].join(' ')`, so these let users find + * the command via synonyms or abbreviations without cluttering the label. + * + * @example `['dedup', 'duplicate', 'clean']` + */ + keywords?: string[]; + /** + * Heading under which this item is grouped in the palette. + * + * Items sharing the same `group` string are rendered together under a + * `Command.Group` heading. Items without a group render ungrouped at + * the top. + */ + group?: string; + /** + * When `true`, selecting this item opens a confirmation dialog before + * executing `onSelect`. The dialog uses `label` as its title and + * `description` as its body text. + * + * Use for irreversible or high-impact operations (bulk close, delete, etc.). + * Defaults to `false` when omitted. + */ + destructive?: boolean; + /** Callback invoked when the user selects this item (after confirmation if `destructive`). */ + onSelect: () => void | Promise; +}; diff --git a/specs/20260314T104746-inline-file-creation.md b/specs/20260314T104746-inline-file-creation.md new file mode 100644 index 0000000000..254ebe2f89 --- /dev/null +++ b/specs/20260314T104746-inline-file-creation.md @@ -0,0 +1,103 @@ +# Inline File Creation & Rename UX + +Replace modal dialogs with inline tree inputs for file/folder creation and rename—matching VS Code, Obsidian, and JetBrains patterns. + +## Current State + +- **CreateDialog.svelte** — Modal dialog pops up center-screen to name new files/folders +- **RenameDialog.svelte** — Same modal approach for rename +- **FileTreeItem.svelte** — Already has context menu (right-click) with New File/Folder/Rename/Delete +- **Toolbar.svelte** — Buttons trigger the modal dialogs +- **FileTree.svelte** — Keyboard nav (arrows, Home/End, Enter/Space) works well + +## Problem + +Modal dialogs for file creation are not idiomatic. Every major file explorer (VS Code, Obsidian, JetBrains, Sublime) uses inline inputs that appear directly in the tree at the insertion point. + +## Design (VS Code Pattern) + +### Inline Creation +1. User clicks "New File" button (toolbar), or right-clicks folder → "New File" (context menu), or presses keyboard shortcut +2. If a folder is selected/focused, auto-expand it and insert an inline input as the **first child** of that folder +3. If a file is selected, insert the inline input as a **sibling** (in the same parent folder) +4. If nothing is selected, insert at root level +5. **Enter** confirms → creates file/folder with that name +6. **Escape** cancels → removes the inline input +7. Clicking outside (blur) also confirms (VS Code behavior) + +### Inline Rename +1. User right-clicks → "Rename", or presses F2 (keyboard shortcut) +2. The name text is replaced with an inline input pre-filled with the current name +3. Text is selected (so typing replaces it) +4. Same Enter/Escape/blur behavior as creation + +### Keyboard Shortcuts (scoped to tree panel) +- **N** — New file (in focused folder or at root) +- **Shift+N** — New folder +- **F2** — Rename focused item +- **Delete** / **Backspace** — Delete focused item (with confirmation dialog—keep DeleteConfirmation.svelte) + +### Context Menu +Already exists. Update actions to trigger inline inputs instead of dialogs. + +## Changes + +### 1. `fs-state.svelte.ts` — Add inline editing state +- `inlineCreate: { parentId: FileId | null; type: 'file' | 'folder' } | null` +- `renamingId: FileId | null` +- Actions: `startCreate(parentId, type)`, `confirmCreate(name)`, `cancelCreate()`, `startRename(id)`, `confirmRename(name)`, `cancelRename()` + +### 2. New: `InlineNameInput.svelte` +- Tiny input that fits inline in the tree item row +- Handles Enter/Escape/blur +- Calls confirm/cancel actions +- Auto-focuses on mount + +### 3. `FileTree.svelte` — Render inline create input & keyboard shortcuts +- After the children of a folder (or at root), render InlineNameInput when inlineCreate matches that location +- Add N, Shift+N, F2, Delete keyboard handlers + +### 4. `FileTreeItem.svelte` — Inline rename support +- When `renamingId === id`, replace name span with InlineNameInput (pre-filled) +- Context menu actions call new state actions instead of opening dialogs + +### 5. `Toolbar.svelte` — Remove dialog state, call inline actions +- "New File" → `fsState.actions.startCreate(parentId, 'file')` +- "New Folder" → `fsState.actions.startCreate(parentId, 'folder')` +- Remove CreateDialog and RenameDialog imports/instances + +### 6. Delete `CreateDialog.svelte` and `RenameDialog.svelte` + +## Todo + +- [x] Add inline editing state and actions to `fs-state.svelte.ts` +- [x] Create `InlineNameInput.svelte` component +- [x] Update `FileTree.svelte` with inline create rendering + keyboard shortcuts +- [x] Update `FileTreeItem.svelte` with inline rename + update context menu actions +- [x] Update `Toolbar.svelte` to use inline actions instead of dialogs +- [x] Delete `CreateDialog.svelte` and `RenameDialog.svelte` +- [x] Verify everything works end-to-end (all diagnostics clean) + +## Review + +### What changed + +**Deleted** (2 files): +- `CreateDialog.svelte` — Modal dialog for file/folder creation +- `RenameDialog.svelte` — Modal dialog for renaming + +**Created** (1 file): +- `InlineNameInput.svelte` — ~68 lines. Small input component with Enter/Escape/blur handling, auto-focus, filename stem selection. + +**Modified** (4 files): +- `fs-state.svelte.ts` — Added `inlineCreate` and `renamingId` state + 6 new actions (`startCreate`, `confirmCreate`, `cancelCreate`, `startRename`, `confirmRename`, `cancelRename`). All centralized, no duplication. +- `FileTree.svelte` — Renders inline create input at root level. Added keyboard shortcuts: N (new file), Shift+N (new folder), F2 (rename), Delete/Backspace (delete). Suppresses tree nav during inline editing. +- `FileTreeItem.svelte` — Renders inline rename (replacing name text) and inline create (inside folder children). Context menu now triggers inline actions with keyboard shortcut hints. Removed CreateDialog/RenameDialog imports. +- `Toolbar.svelte` — Buttons now call `fsState.actions.startCreate/startRename` directly. Removed all dialog state and imports. + +### Net result +- **2 components deleted**, 1 created → net -1 component +- **Duplicated dialog state eliminated** (was in both Toolbar and every FileTreeItem) +- **N×3 hidden dialog instances removed** (every tree item was mounting CreateDialog + RenameDialog + DeleteConfirmation; now only DeleteConfirmation remains per-item) +- **Centralized editing state** in fs-state singleton — one source of truth +- **DeleteConfirmation stays** as a modal (correct—destructive actions need explicit confirmation) diff --git a/specs/20260314T170000-surgical-sqlite-index-updates.md b/specs/20260314T170000-surgical-sqlite-index-updates.md new file mode 100644 index 0000000000..3cbb02f34c --- /dev/null +++ b/specs/20260314T170000-surgical-sqlite-index-updates.md @@ -0,0 +1,190 @@ +# Surgical SQLite Index Updates + +**Date**: 2026-03-14 +**Status**: Implemented +**Author**: AI-assisted + +## Overview + +Replace the full nuke-and-rebuild strategy in the SQLite index extension with surgical per-row updates. When a file is edited, only that file's row in SQLite + FTS is updated—not the entire database. + +## Motivation + +### Current State + +`packages/filesystem/src/extensions/sqlite-index/index.ts` rebuilds the entire in-memory SQLite database on every Yjs table mutation: + +```typescript +// Line 182 — observe fires on ANY table change +unobserve = filesTable.observe(() => scheduleSync()); + +// scheduleSync debounces 100ms, then calls rebuild() + +async function rebuild(): Promise { + const rows = filesTable.getAllValid(); // Read ALL rows + const paths = computePaths(rows); // Compute ALL paths + for (const row of rows) { // Read ALL content docs + const handle = await contentDocs.open(row.id); + const text = handle.read(); + } + // DELETE everything, INSERT everything + await client.batch([ + 'DELETE FROM files_fts', + 'DELETE FROM files', + ...insertStatements + ], 'write'); +} +``` + +This creates problems: + +1. **O(N) content reads on every mutation**: Editing one file triggers `contentDocs.open()` for every file in the workspace. Content reads are async and sequential—this is the bottleneck. +2. **Wasted work**: A single rename re-reads all content, recomputes all paths, and rewrites all rows. +3. **Missed mutations under load**: The `rebuilding` guard flag silently drops a rebuild if one is already in progress. If a rebuild takes >100ms (the debounce window), a mutation can be lost. + +### Desired State + +The observer receives `changedIds: Set`. For each changed ID: +- Deleted row → `DELETE` from `files` + `files_fts` +- Added/updated row → read only that row's content, compute only that row's path, upsert only that row + +Full rebuild only happens on initial load and manual recovery. + +## Design Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Keep debouncing | Yes, same 100ms | Coalesces rapid edits (typing) into one batch of surgical updates | +| Path cascade on folder rename | Query SQLite for descendants | Folder renames are rare; querying `WHERE path LIKE '/old/%'` is fast on in-memory SQLite | +| Keep full `rebuild()` | Yes, for init + recovery | No SQLite state on page load—need a full build. Exposed `rebuild()` stays for corruption recovery | +| Path computation for single row | Walk `parentId` chain via `filesTable.get()` | Reuses existing Yjs reads, no need for a separate index | + +## Architecture + +``` +CURRENT FLOW (every mutation): +──────────────────────────────── +filesTable.observe() → debounce 100ms → rebuild() + → getAllValid() (N rows) + → computePaths() (N walks) + → contentDocs.open() (N async reads) + → DELETE all + INSERT all (2N+2 statements) + +NEW FLOW (per mutation): +──────────────────────────────── +filesTable.observe(changedIds) → debounce 100ms → syncRows(changedIds) + → for each changedId: + → filesTable.get(id) (1 read) + → computePathForRow() (1 walk) + → contentDocs.open(id) (1 async read, files only) + → DELETE + INSERT row (4 statements per row) + → if folder renamed: + → query descendants (1 SELECT) + → recompute descendant paths + → batch upsert descendants + +INITIAL LOAD (unchanged): +──────────────────────────────── +rebuild() — same as current full nuke-and-rebuild +``` + +## Implementation Plan + +### Phase 1: Add `syncRows()` and single-row path computation + +- [x] **1.1** Add `computePathForRow(id, filesTable)` function that walks the `parentId` chain using `filesTable.get()` calls (not `getAllValid()`) +- [x] **1.2** Add `syncRows(changedIds: Set)` async function that handles add/update/delete per row +- [x] **1.3** Change the observer from `filesTable.observe(() => scheduleSync())` to `filesTable.observe((changedIds) => scheduleSync(changedIds))` — pass changed IDs through the debounce + +### Phase 2: Handle folder rename cascading + +- [x] **2.1** In `syncRows`, detect when a changed row is a folder whose `path` in SQLite differs from its newly computed path +- [x] **2.2** Query SQLite for descendants: `SELECT id FROM files WHERE path LIKE ?` using the old path prefix +- [x] **2.3** Recompute paths for all descendants and include their upserts in the same batch + +### Phase 3: Wire up and clean up + +- [x] **3.1** Switch the observer to call `scheduleSync(changedIds)` instead of `scheduleSync()` +- [x] **3.2** Update `scheduleSync` to accumulate changed IDs across debounce window (union of all sets) +- [x] **3.3** Keep `rebuild()` for initial load (`whenReady`) and the public `rebuild()` export +- [x] **3.4** Remove the `rebuilding` guard flag—no longer needed since `syncRows` is incremental + +## Edge Cases + +### Rapid folder rename + file edit in same debounce window + +1. User renames folder `/docs` → `/notes` +2. Within 100ms, user edits `/notes/readme.md` +3. Both IDs land in the same `changedIds` set +4. `syncRows` processes the folder first (recomputes descendants), then the file (which now has the correct parent path) +5. **Resolution**: Process folders before files in each batch. Sort `changedIds` so folder-type rows are handled first. + +### File deleted before syncRows runs + +1. User deletes a file +2. 100ms later, `syncRows` fires with that ID +3. `filesTable.get(id)` returns `not_found` +4. **Resolution**: Already handled—`not_found` triggers `DELETE` from SQLite. + +### Initial load (empty SQLite) + +1. Page loads, SQLite is `:memory:` with empty tables +2. No prior state to diff against +3. **Resolution**: `rebuild()` runs as before during `whenReady`. Surgical updates only kick in after init. + +### Content doc fails to open + +1. `contentDocs.open(id)` throws for a specific file +2. **Resolution**: Same as current—catch and set content to `null`. File is still searchable by name. + +## Open Questions + +1. **Should we accumulate or replace changedIds across debounce resets?** + - Current code resets the timer on each new mutation. If we accumulate IDs, rapid edits to different files get batched together. If we replace, only the latest mutation's IDs survive. + - **Recommendation**: Accumulate (union). This ensures no mutations are lost during rapid activity. + +2. **Should folders be processed before files in a batch?** + - If a folder rename and a child file edit happen in the same batch, processing order matters for path correctness. + - **Recommendation**: Yes, sort changedIds so folders come first. Query `filesTable.get(id)` for each to check type. + +## Success Criteria + +- [ ] Editing a file only triggers 1 `contentDocs.open()` call (not N) +- [ ] Renaming a file updates 1 row in SQLite (not N) +- [ ] Renaming a folder updates the folder + its descendants (not all rows) +- [ ] Deleting a file removes 1 row from SQLite (not rebuilds everything) +- [ ] Initial page load still does a full rebuild +- [ ] `rebuild()` is still callable for manual recovery +- [ ] FTS search results remain correct after surgical updates +- [ ] No regressions in existing behavior + +## References + +- `packages/filesystem/src/extensions/sqlite-index/index.ts` — Main file being modified +- `packages/filesystem/src/extensions/sqlite-index/schema.ts` — SQLite schema (unchanged) +- `packages/filesystem/src/extensions/sqlite-index/ddl.ts` — DDL generation (unchanged) +- `packages/workspace/src/workspace/table-helper.ts` — Observer API providing `changedIds: Set` +- `packages/workspace/src/shared/y-keyvalue/y-keyvalue-lww.ts` — Underlying change types (`add`/`update`/`delete`) +- `apps/opensidian/src/lib/fs/fs-state.svelte.ts` — Consumer wiring `createSqliteIndex()` into workspace + +## Review + +**Completed**: 2026-03-14 + +### Summary + +Replaced the full nuke-and-rebuild strategy with surgical per-row updates in `index.ts`. The observer now forwards `changedIds` through a debounced `scheduleSync` that accumulates IDs and flushes them to `syncRows`. Editing a file touches only that file's row. Folder renames cascade to descendants via a SQLite `LIKE` query. Full `rebuild()` is preserved for initial load and manual recovery. + +### Changes + +- **`computePathForRow(id, filesTable)`**: New module-level function. Walks `parentId` chain via `filesTable.get()` calls (not bulk `getAllValid()`). Mirrors `computePaths` behavior for cycles/orphans. +- **`syncRows(changedIds)`**: New function inside factory closure. Classifies rows as deleted/folder/file, processes folders before files, reads content only for non-folders, batches all statements in one `client.batch()` call. +- **Folder rename cascading**: After upserting a folder, queries SQLite for descendants whose path starts with the old prefix. Recomputes descendant paths by string replacement and includes `UPDATE` statements in the same batch. +- **`scheduleSync(changedIds)`**: Now accepts and accumulates `Set` across debounce resets. Flushes accumulated set to `syncRows` when timer fires. +- **Observer**: Changed from `() => scheduleSync()` to `(changedIds) => scheduleSync(changedIds)`. +- **`rebuilding` guard removed**: No longer needed since `syncRows` is incremental and doesn't conflict with itself. +- **`rebuild()`**: Kept exactly as-is for initial load and public export, minus the `try/finally` wrapper that only existed for the guard flag. + +### Deviations from Spec + +- None. Implementation followed the spec exactly.