Skip to content

refactor(opensidian): consolidate state, eliminate duplication, and reorganize by concern#1541

Merged
braden-w merged 13 commits intomainfrom
opencode/proud-garden
Mar 19, 2026
Merged

refactor(opensidian): consolidate state, eliminate duplication, and reorganize by concern#1541
braden-w merged 13 commits intomainfrom
opencode/proud-garden

Conversation

@braden-w
Copy link
Member

Opensidian's single createFsState() factory was doing too much—creating the workspace infrastructure, managing UI reactive state, and proxying infra internals through its return object. Meanwhile, dialog components were instantiated per tree node (300+ instances for 100 files), tree traversal logic was duplicated across components, and path lookups were O(n) scans.

This PR splits the architecture into two clean layers, eliminates the duplication, and reorganizes files by concern.

Workspace vs. UI state separation

createFsState() previously owned both workspace creation (Yjs, extensions, filesystem) and Svelte reactive state (active file, open tabs, dialogs, focus). Now they're separate modules:

state/
  workspace.ts        ← Pure TypeScript. Creates workspace + filesystem. No Svelte.
  fs-state.svelte.ts  ← Svelte runes only. Imports workspace, owns UI state.
// workspace.ts — infrastructure, zero Svelte dependencies
export const ws = createWorkspace({
  id: 'opensidian',
  tables: { files: filesTable },
})
  .withExtension('persistence', indexeddbPersistence)
  .withWorkspaceExtension('sqliteIndex', createSqliteIndex());

export const fs = createYjsFileSystem(ws.tables.files, ws.documents.files.content);
// fs-state.svelte.ts — imports workspace, owns only reactive state
import { fs, ws } from '$lib/workspace';

function createFsState() {
  let version = $state(0);
  let activeFileId = $state<FileId | null>(null);
  // ... UI state only, no workspace creation
}

Components that need raw infra (Toolbar for sample data, ContentEditor for document handles) import workspace.ts directly instead of reaching through fsState.fs or fsState.documents.

Dialog consolidation

Before: 3 dialogs (Create, Rename, Delete) instantiated in both FileTreeItem and Toolbar—7 duplicate $state(false) declarations, 7 duplicate helper functions, and N×3 dialog instances (one set per tree node).

After: Dialog state lives in fsState, dialogs render once in AppShell, and context menu handlers are one-liners:

<!-- Before: FileTreeItem.svelte (101 lines) -->
<script>
  let createDialogOpen = $state(false);           // ← duplicated in Toolbar
  let createDialogMode = $state<'file' | 'folder'>('file');  // ← duplicated
  let renameDialogOpen = $state(false);           // ← duplicated
  let deleteDialogOpen = $state(false);           // ← duplicated

  function selectAndOpenCreate(mode) { ... }      // ← duplicated
  function selectAndOpenRename() { ... }          // ← duplicated
  function selectAndOpenDelete() { ... }          // ← duplicated
</script>
<CreateDialog bind:open={createDialogOpen} mode={createDialogMode} />
<RenameDialog bind:open={renameDialogOpen} />
<DeleteConfirmation bind:open={deleteDialogOpen} />

<!-- After: FileTreeItem.svelte (84 lines) -->
<ContextMenu.Item onclick={() => {
  fsState.actions.selectFile(id);
  fsState.actions.openCreate('file');
}}>
  New File
</ContextMenu.Item>

withErrorToast helper

Five actions had identical try/catch/toast.error/console.error blocks. Now they use a shared zone-3 helper:

// Before (repeated 5 times):
async createFile(parentId, name) {
  try {
    await fs.writeFile(path, '');
    toast.success(`Created ${path}`);
  } catch (err) {
    toast.error(err instanceof Error ? err.message : 'Failed to create file');
    console.error(err);
  }
}

// After:
async createFile(parentId, name) {
  await withErrorToast(async () => {
    await fs.writeFile(path, '');
    toast.success(`Created ${path}`);
  }, 'Failed to create file');
}

walkTree<T> generic traversal

FileTree and CommandPalette both had 15-20 line recursive tree walks with the same getChildIds→getRow→null-check→recurse pattern. Now they use a shared method:

// FileTree: 15 lines → 4
const visibleIds = $derived.by(() => {
  return fsState.walkTree<FileId>((id, row) => ({
    collect: id,
    descend: row.type === 'folder' && fsState.expandedIds.has(id),
  }));
});

// CommandPalette: 20 lines → 8
const allFiles = $derived.by(() => {
  if (!open) return [];
  return fsState.walkTree<FileEntry>((id, row) => {
    if (row.type === 'file') {
      const fullPath = fsState.getPathForId(id) ?? '';
      const lastSlash = fullPath.lastIndexOf('/');
      const parentDir = lastSlash > 0 ? fullPath.slice(1, lastSlash) : '';
      return { collect: { id, name: row.name, parentDir }, descend: false };
    }
    return { descend: true };
  });
});

O(1) path lookups

getPathForId(id) and selectedPath were iterating all paths linearly. Added a reverse idToPath map to createFileSystemIndex in @epicenter/filesystem:

// Before — O(n) per lookup:
for (const p of fs.index.allPaths()) {
  if (fs.index.getIdByPath(p) === id) return p;
}

// After — O(1):
return fs.index.getPathById(id) ?? null;

File reorganization

src/lib/
├── workspace.ts                        ← NEW: pure infra
├── state/
│   └── fs-state.svelte.ts              ← moved from fs/
├── utils/
│   └── file-icons.ts                   ← moved from fs/
└── components/
    ├── AppShell.svelte                 ← renders dialogs once
    ├── Toolbar.svelte
    ├── CommandPalette.svelte
    ├── tree/
    │   ├── FileTree.svelte
    │   └── FileTreeItem.svelte
    ├── editor/
    │   ├── ContentPanel.svelte
    │   ├── ContentEditor.svelte
    │   ├── CodeMirrorEditor.svelte
    │   ├── TabBar.svelte
    │   └── PathBreadcrumb.svelte
    └── dialogs/
        ├── CreateDialog.svelte
        ├── RenameDialog.svelte
        └── DeleteConfirmation.svelte

braden-w added 12 commits March 18, 2026 14:11
… O(1) path lookups

- Consolidate dialog open/close state into fsState factory (zone 2)
- Add openCreate/closeCreate/openRename/closeRename/openDelete/closeDelete actions
- Extract withErrorToast helper to eliminate 5 duplicate try/catch/toast patterns
- Add walkTree<T> generic tree traversal method for reuse by components
- Add idToPath reverse map to FileSystemIndex for O(1) getPathById lookups
- Replace O(n) allPaths() iteration in selectedPath and getPathForId with O(1) lookups
…tree and toolbar

- Dialogs now read open/mode state from fsState singleton (no props)
- AppShell renders CreateDialog, RenameDialog, DeleteConfirmation once
- FileTreeItem: stripped 4 dialog state vars, 3 helper fns, 3 dialog instances
- Toolbar: stripped 4 dialog state vars, 4 open fns, 3 dialog instances
- Context menu handlers inline fsState.actions.selectFile + openCreate/Rename/Delete
… CommandPalette

- FileTree.visibleIds: replaced 15-line recursive walk with 4-line walkTree call
- CommandPalette.allFiles: replaced 20-line recursive collect with 8-line walkTree call
- Both now delegate the getChildIds/getRow/null-check loop to fsState.walkTree
- /fs/fs-state.svelte -> /state/fs-state.svelte
- /fs/file-icons -> /utils/file-icons
- Relative component imports updated for tree/, editor/, dialogs/ subdirectories
…pace.ts

Split createFsState() into two modules so workspace infrastructure
(Yjs workspace, filesystem, extensions) lives in pure TypeScript
separate from Svelte reactive UI state. Consumers that only need
infra (Toolbar, ContentEditor) now import directly from workspace.ts.
…posures

Match monorepo convention (tab-manager, whispering) by placing workspace
infrastructure at lib/ root instead of state/. Remove window.workspace
and window.fsState debug exposures—can be re-added when needed.
Remove documents, filesDb, and sqliteIndex aliases from workspace.ts.
Export ws directly so consumers access ws.tables.files and
ws.documents.files.content without intermediate variables.
Resolve 9 conflicts between the PR's architectural improvements and
main's inline-editing UI evolution:

- Keep workspace.ts extraction, walkTree, O(1) path lookups, withErrorToast
- Adopt main's InlineNameInput for create/rename (delete Create/RenameDialog)
- Adopt main's flattened API surface (no actions namespace)
- Adopt main's command-palette upgrade and @lucide/svelte imports
- Centralize DeleteConfirmation state in fsState, render once in AppShell
- Move InlineNameInput into tree/ subdirectory to match file reorganization
…criminated union

Replace renamingId/inlineCreate/deleteDialogOpen with a single
InteractionMode union so impossible state combinations are
unrepresentable. Add contextMenuTargetId for hover persistence
while a context menu is open, and fix the InlineNameInput blur
race that caused 'New File' from context menu to silently cancel.
Reword derived-getters comment to indicate permanent API (not
migration scaffolding). Rename blurConfirmed → confirmed and
document why the rAF blur deferral exists.
@github-actions
Copy link
Contributor

github-actions bot commented Mar 19, 2026

Thank you for your contribution! Before we can merge this PR, we need you to sign our Contributor License Agreement. This is a one-time requirement—it lets us offer commercial licenses for the sync server while you retain full copyright on your code.

To sign, please comment on this PR with:

I have read the CLA Document and I hereby sign the CLA


I have read the CLA Document and I hereby sign the CLA


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

…ring skill

Positive framing for when keeping 1-3 line duplications inline
is more readable than extracting a helper. Includes readability
test, concrete examples, and threshold for when extraction IS
worth it.
@braden-w braden-w merged commit 935dcec into main Mar 19, 2026
1 of 10 checks passed
@braden-w braden-w deleted the opencode/proud-garden branch March 19, 2026 18:42
@github-actions github-actions bot locked and limited conversation to collaborators Mar 19, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant