Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .agents/skills/workspace-api/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,56 @@ return { ...row, views: 0, _v: 2 }; // Works — contextual narrowing
return { ...row, views: 0, _v: 2 as const }; // Also works — redundant
```

## Document Content (Per-Row Y.Docs)

Tables with `.withDocument()` create a content Y.Doc per row. Content is stored using a **timeline model**: a `Y.Array('timeline')` inside the Y.Doc, where each entry is a typed `Y.Map` supporting text, binary, and sheet modes.

### Reading and Writing Content

Use the filesystem package's content helpers, which read/write via the timeline:

```typescript
import { createYjsFileSystem } from '@epicenter/filesystem';

const fs = createYjsFileSystem(ws.tables.files, ws.documents.files.content);

// Read content (timeline-backed)
const text = await fs.content.read(fileId);

// Write content (timeline-backed)
await fs.content.write(fileId, 'hello');
```

### Anti-Patterns

**Do not use `handle.read()`/`handle.write()`** for apps that also use the filesystem API. These methods read/write from `Y.Text('content')`—a different shared type than the timeline—causing silent data loss:

```typescript
// ❌ BAD: handle uses Y.Text('content'), filesystem uses Y.Array('timeline')
const handle = await ws.documents.files.content.open(id);
handle.read(); // reads from wrong shared type
handle.write('x'); // writes to wrong shared type

// ✅ GOOD: use fs.content which reads/writes via timeline
await fs.content.read(id);
await fs.content.write(id, 'hello');
```

**Do not access `handle.ydoc` directly for content:**

```typescript
// ❌ BAD: bypasses all abstractions
const ytext = handle.ydoc.getText('content');
const fragment = handle.ydoc.getXmlFragment('content');

// ✅ GOOD: use timeline abstraction
import { createTimeline } from '@epicenter/filesystem';
const tl = createTimeline(handle.ydoc);
const text = tl.readAsString();
```

See `specs/20260313T224500-unify-document-content-model.md` for the full unification plan.

## References

- `packages/workspace/src/workspace/define-table.ts`
Expand Down
25 changes: 25 additions & 0 deletions .agents/skills/yjs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,31 @@ yarray.push([sameItem]); // Different Y.Map instance internally

Any concurrent edits to the "moved" item are lost because you deleted the original.

### 6. Accessing Raw Y.Doc Shared Types for Document Content

Document Y.Docs in this codebase use a timeline model (`Y.Array('timeline')` with nested typed entries). Accessing top-level shared types directly writes to a different location than the timeline, causing silent data loss:

```typescript
// BAD: writes to Y.Text('content'), not the timeline
const ytext = handle.ydoc.getText('content');
ytext.insert(0, 'hello'); // invisible to fs.readFile()

// BAD: handle.read/write also uses Y.Text('content')
handle.read(); // reads from wrong shared type
handle.write('x'); // writes to wrong shared type

// GOOD: use filesystem content helpers (timeline-backed)
await fs.content.read(id);
await fs.content.write(id, 'hello');

// GOOD: use timeline abstraction directly if needed
import { createTimeline } from '@epicenter/filesystem';
const tl = createTimeline(handle.ydoc);
const text = tl.readAsString();
```

See `specs/20260313T224500-unify-document-content-model.md`.

## Debugging Tips

### Inspect Document State
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ Destructive actions need approval: Force pushes, hard resets (`--hard`), branch
Token-efficient execution: When possible, delegate to sub-agent with only the command. Instruct it to execute without re-analyzing.

Writing conventions: Load `writing-voice` skill for any user-facing text—UI strings, tooltips, error messages, docs. Em dashes are always closed (no spaces).

Content model: Document content uses the timeline model (`Y.Array('timeline')`). Never access `handle.ydoc.getText('content')` or use `handle.read()`/`handle.write()` alongside filesystem APIs—use `fs.content.read/write` or `createTimeline(ydoc)` from `@epicenter/filesystem` instead. See `specs/20260313T224500-unify-document-content-model.md`.
3 changes: 2 additions & 1 deletion apps/opensidian/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@
},
"devDependencies": {
"@epicenter/filesystem": "workspace:*",
"@epicenter/workspace": "workspace:*",
"@epicenter/ui": "workspace:*",
"@epicenter/workspace": "workspace:*",
"@sveltejs/adapter-auto": "catalog:",
"@sveltejs/kit": "catalog:",
"@sveltejs/vite-plugin-svelte": "catalog:",
"@tailwindcss/vite": "catalog:",
"bun-types": "catalog:",
"lucide-svelte": "^0.577.0",
"svelte": "catalog:",
"svelte-check": "catalog:",
"svelte-sonner": "catalog:",
Expand Down
26 changes: 16 additions & 10 deletions apps/opensidian/src/lib/components/ContentEditor.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import type { FileId } from '@epicenter/filesystem';
import { onMount } from 'svelte';
import { fsState } from '$lib/fs/fs-state.svelte';
import { Textarea } from '@epicenter/ui/textarea';

type Props = {
fileId: FileId;
Expand Down Expand Up @@ -29,9 +29,7 @@
dirty = false;
}

function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement;
content = target.value;
function handleInput() {
dirty = true;
}

Expand All @@ -42,10 +40,18 @@
}
}

// Load content when fileId changes
// Load content when fileId changes; save dirty content on cleanup.
// The {#key} block in ContentPanel destroys this component when
// activeFileId changes—DOM removal doesn't fire blur, so the
// cleanup function is the only chance to persist unsaved edits.
$effect(() => {
void fileId;
const id = fileId;
loadContent();
return () => {
if (dirty) {
fsState.actions.writeContent(id, content);
}
};
});
</script>

Expand All @@ -56,13 +62,13 @@
Loading...
</div>
{:else}
<textarea
class="h-full w-full resize-none border-0 bg-transparent p-4 font-mono text-sm outline-none focus:ring-0"
value={content}
<Textarea
class="h-full w-full resize-none border-0 shadow-none rounded-none bg-transparent p-4 font-mono text-sm outline-none focus-visible:ring-0 focus-visible:border-transparent"
bind:value={content}
oninput={handleInput}
onblur={saveContent}
onkeydown={handleKeydown}
spellcheck={false}
placeholder="Empty file"
></textarea>
/>
{/if}
18 changes: 11 additions & 7 deletions apps/opensidian/src/lib/components/CreateDialog.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<script lang="ts">
import { Button } from '@epicenter/ui/button';
import * as Dialog from '@epicenter/ui/dialog';
import { Field, FieldLabel } from '@epicenter/ui/field';
import { Input } from '@epicenter/ui/input';
import { fsState } from '$lib/fs/fs-state.svelte';

type Props = {
Expand Down Expand Up @@ -45,13 +47,15 @@
</Dialog.Description>
</Dialog.Header>
<form onsubmit={handleSubmit}>
<input
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
type="text"
placeholder={mode === 'file' ? 'filename.txt' : 'folder-name'}
bind:value={name}
autofocus
>
<Field>
<FieldLabel>Name</FieldLabel>
<Input
type="text"
placeholder={mode === 'file' ? 'filename.txt' : 'folder-name'}
bind:value={name}
autofocus
/>
</Field>
<Dialog.Footer class="mt-4">
<Button variant="outline" type="button" onclick={() => (open = false)}>
Cancel
Expand Down
24 changes: 14 additions & 10 deletions apps/opensidian/src/lib/components/FileTree.svelte
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
<script lang="ts">
import * as Empty from '@epicenter/ui/empty';
import * as TreeView from '@epicenter/ui/tree-view';
import { fsState } from '$lib/fs/fs-state.svelte';
import TreeNode from './TreeNode.svelte';
import FileTreeItem from './FileTreeItem.svelte';
</script>

{#if fsState.rootChildIds.length === 0}
<div
class="flex flex-col items-center justify-center gap-2 p-8 text-center text-sm text-muted-foreground"
>
<p>No files yet</p>
<p class="text-xs">Use the toolbar to create files and folders</p>
</div>
<Empty.Root class="border-0">
<Empty.Header>
<Empty.Title>No files yet</Empty.Title>
<Empty.Description
>Use the toolbar to create files and folders</Empty.Description
>
</Empty.Header>
</Empty.Root>
{:else}
<div class="flex flex-col gap-0.5" role="tree">
<TreeView.Root class="gap-0.5">
{#each fsState.rootChildIds as childId (childId)}
<TreeNode id={childId} depth={0} />
<FileTreeItem id={childId} />
{/each}
</div>
</TreeView.Root>
{/if}
104 changes: 104 additions & 0 deletions apps/opensidian/src/lib/components/FileTreeItem.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<script lang="ts">
import type { FileId } from '@epicenter/filesystem';
import * as ContextMenu from '@epicenter/ui/context-menu';
import * as TreeView from '@epicenter/ui/tree-view';
import { File as FileIcon } from 'lucide-svelte';
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';

let { id }: { id: FileId } = $props();

const row = $derived(fsState.getRow(id));
const isFolder = $derived(row?.type === 'folder');
const isExpanded = $derived(fsState.expandedIds.has(id));
const isSelected = $derived(fsState.activeFileId === id);
const children = $derived(isFolder ? fsState.getChildIds(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;
}
</script>

{#if row}
<ContextMenu.Root>
<ContextMenu.Trigger>
{#snippet child({ props })}
{#if isFolder}
<div {...props} role="treeitem" aria-expanded={isExpanded}>
<TreeView.Folder
name={row.name}
open={isExpanded}
onOpenChange={() => fsState.actions.toggleExpand(id)}
class="w-full rounded-sm px-2 py-1 text-sm hover:bg-accent {isSelected
? 'bg-accent text-accent-foreground'
: ''}"
>
{#each children as childId (childId)}
<FileTreeItem id={childId} />
{/each}
</TreeView.Folder>
</div>
{:else}
<TreeView.File
{...props}
name={row.name}
class="w-full rounded-sm px-2 py-1 text-sm hover:bg-accent {isSelected
? 'bg-accent text-accent-foreground'
: ''}"
onclick={() => fsState.actions.selectFile(id)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
fsState.actions.selectFile(id);
}
}}
role="treeitem"
>
{#snippet icon()}
<FileIcon class="h-4 w-4 shrink-0 text-muted-foreground" />
{/snippet}
</TreeView.File>
{/if}
{/snippet}
</ContextMenu.Trigger>
<ContextMenu.Content>
{#if isFolder}
<ContextMenu.Item onclick={() => selectAndOpenCreate('file')}>
New File
</ContextMenu.Item>
<ContextMenu.Item onclick={() => selectAndOpenCreate('folder')}>
New Folder
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
<ContextMenu.Item onclick={selectAndOpenRename}>Rename</ContextMenu.Item>
<ContextMenu.Item class="text-destructive" onclick={selectAndOpenDelete}>
Delete
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>

<CreateDialog bind:open={createDialogOpen} mode={createDialogMode} />
<RenameDialog bind:open={renameDialogOpen} />
<DeleteConfirmation bind:open={deleteDialogOpen} />
{/if}
18 changes: 11 additions & 7 deletions apps/opensidian/src/lib/components/RenameDialog.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<script lang="ts">
import { Button } from '@epicenter/ui/button';
import * as Dialog from '@epicenter/ui/dialog';
import { Field, FieldLabel } from '@epicenter/ui/field';
import { Input } from '@epicenter/ui/input';
import { fsState } from '$lib/fs/fs-state.svelte';

type Props = {
Expand Down Expand Up @@ -38,13 +40,15 @@
<Dialog.Description>Enter a new name.</Dialog.Description>
</Dialog.Header>
<form onsubmit={handleSubmit}>
<input
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
type="text"
placeholder="new-name"
bind:value={name}
autofocus
>
<Field>
<FieldLabel>Name</FieldLabel>
<Input
type="text"
placeholder="new-name"
bind:value={name}
autofocus
/>
</Field>
<Dialog.Footer class="mt-4">
<Button variant="outline" type="button" onclick={() => (open = false)}>
Cancel
Expand Down
Loading
Loading