Commits
The cm-hr line decoration that draws the rule was applied unconditionally,
so placing the cursor on a horizontal rule showed both the rule and the raw
"---". Only style the line (and hide its glyphs) when the cursor is on a
different line; on the rule's own line, emit nothing so the raw "---" shows
for editing — matching how headings/blockquotes reveal their markers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`.cm-hr` is a line decoration on a `.cm-line`, and it used `margin: 1em 0`
for the rule's vertical spacing. A vertical margin on a line is not part of
the box CodeMirror measures for its height map, so each `---` added ~32px of
height that CM6 didn't account for — shifting the caret/click mapping for
every line below the rule downward, compounding with each additional rule.
This read as "clicking anywhere puts the cursor a couple lines too low."
Use padding (which is inside the measured box) for the spacing and draw the
rule with a centered pseudo-element instead of a top border.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reserve the CodeMirror scroller's scrollbar gutter with
`scrollbar-gutter: stable` so content no longer shifts left when a
document first grows tall enough to need a vertical scrollbar.
Add a non-passive wheel listener to the tile tab strip so a vertical
mouse wheel scrolls it horizontally while the pointer is over it
(trackpad horizontal gestures still work via the dominant axis).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pressing Enter after a code fence left the new line outside the block
background until the closing ``` was typed. The per-line loop decremented
endLine whenever node.to sat at a line start — but @lezer/markdown only
puts node.to at a line start for an *unterminated* block (the newline just
typed), never for a closed one (whose node ends on the closing-fence line).
So the decrement only ever dropped the active editing line. Remove it: the
loop now paints every line the node spans, keeping the cursor's line — and
the closing ``` — inside the block, while closed blocks still stop at the
fence line and never paint the blank line after.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cm-codeblock line decoration carried vertical margin, per-line
padding, and border-radius, so each code line rendered as its own rounded
box with the editor background showing through the gaps between lines.
Drop the vertical margin and give the block horizontal padding only, then
tag the first/last block lines (cm-codeblock-first / -last) to add the
vertical padding and rounded corners — the run now reads as one
continuous box.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cm-inline-code was applied only to the text between the backticks, so
when the markers were visible (cursor near) the highlight box stopped
short of them. Style the whole InlineCode node instead — backticks
included — matching the bold/italic content-mark pattern. When the
backticks are hidden they collapse to zero width inside the same box, so
the far-cursor appearance is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Proximity reveal used a 2-char window, so after typing a closing marker
(e.g. the second *) the markers stayed visible until the cursor moved 3
characters past them. Set PROXIMITY_CHARS to 0 so markers reveal only
while the cursor is within/touching the span and hide the moment it moves
one character past the closing marker. Affects bold, italic, strikethrough,
inline code, and links.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restructure the Automerge editor's single livePreview.ts into per-node
CodeMirror extensions composed by livePreview(), mirroring the reams
reference. Extends coverage from 4 node types to the core CommonMark +
GFM set (no widgets):
- headings: ported as-is
- bold / italic / strikethrough: content classes + proximity-revealed
markers, registered atomic so cursor motion skips hidden markers
- inline code + fenced code blocks: content/line styling + fence hiding
- links: fix the broken handler (hide all brackets/paren/URL, style the
label) + cmd/ctrl-click to open externally via a new shell.openExternal
IPC bridge
- blockquotes, horizontal rules, lists: line decorations + prefix hiding
Splits the single cm-formatting-hidden class into idiom-correct hide
classes (per the CM6 cursor-skip contract) and activates the previously
dead CSS. Adds headless builder tests (pure over EditorState) covering
each node type.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drag a file in the sidebar onto a directory (or the vault root) to move it.
- FileSystemProvider gains moveFile(entryId, targetDirId) and a rootId.
Local implementation renames across directories (guards same-location
and name conflicts); the synced provider syncs afterward like rename.
- VaultTree: files are draggable (independent off-screen drag ghost);
every row carries data-drop-dir for its enclosing folder, and the tree
container resolves the drop target via closest() — so it picks the
deepest folder under the cursor, falls through to the root over empty
space / root-level files, and clears reliably. The target directory's
whole subtree (or the whole tree, for the root) is highlighted.
- AppSidebar: handleMove calls provider.moveFile then refreshes the tree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Split Right / Split Down move from the bottom bar to the top bar as
icon buttons (SplitRightIcon / SplitDownIcon) with hover tooltips, left
of the workspace selector, shown only in tiling mode. Wired through the
tiling handle (App.handleSplitRight/Down).
- Title bar: compact fixed-height buttons (size-5) with header padding so
hover highlights no longer reach the screen edge; tooltip + pointer
cursor on the sidebar toggle, split buttons, and workspace menu.
- Sidebar: fix asymmetric collapse animation — the open state now also
transitions width, so re-opening animates instead of snapping.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drag tab chips to reorder, move between tiles, or split.
- tabsModel.moveTab: reorder within / move across tiles (pure + tested);
creates the destination tile if absent and reports sourceEmptied.
- tabDnd: shared drag payload + computeDropZone (5 zones; pure + tested).
- model.splitLeaf gains `before` so a drop on a left/top edge puts the
new tile first.
- tiling.tsx: TilingTile shows a translucent zone overlay (raised via a
capture overlay so the editor can't swallow drag events); dropping on
an edge splits the tile and moves the tab; center moves it in.
- TileTabStrip: draggable chips with an insertion marker, cleared on
dragleave/dragend; an independent off-screen clone is used as the drag
image so the clipped live chip can't corrupt it. Thin, non-overlay tab
scrollbar (TileTabStrip.css).
Fixes found while testing:
- White-screen crash: read currentTarget synchronously, not inside a
setState updater (React nulls it after the handler).
- Tiles overlapped: split panes now flex-grow to share the space the
fixed splitter leaves, so panes + divider sum to 100% with no
hardcoded splitter width.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each vault's tile layout and tabs now survive an app restart, along with
the selected vault and (pre-existing) workspace kind.
- tilingPersistence: serializeLayout / buildInitialTileTabs / maxIdSuffix
(pure + tested) and load/save to localStorage under
textile.tiling.<vaultId>. Persists the split tree, active tile, and
each tile's documents + active index; not handles or load state.
- useTilingDocumentSlots: accept initialTiles, seed the tab map with
loading tabs, and re-open the documents in a mount-only effect
(cancelled on unmount). Exposes tileTabs for serialization.
- TilingWorkspace: lazy-init tree/activeTile/id-counter from the
persisted layout (keyed by instanceKey = vault provider id) and write
on change (skipping no-op rewrites).
- App: persist + restore selectedProviderId (textile.selectedVault); the
registry loads vaults synchronously so the restored vault survives the
auto-select effects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tab management via right-click, a shared context-menu provider, and a
sidebar action to open a file in a new tile.
- tabsModel: closeOthers / closeToRight / closeToLeft (anchor survives)
and closeAllOfFile (across tiles, reports emptiedTileIds), all pure +
tested. Shared slotShowsRequest helper.
- TileTabStrip: right-click -> native context menu of close actions
(unified onTabAction dispatcher). useTilingDocumentSlots gains the
matching close methods; tiling.tsx collapses tiles emptied by
close-all-of-file.
- platform/contextMenu: ContextMenuProvider + useContextMenu inject the
context-menu capability (electron impl is the one place touching
window.textile). Provider mounted at the App composition root; TileTabStrip
and the sidebar consume via the hook instead of window.
- Sidebar file menu: "Open in new tile to the right/below" splits the
active tile then opens the file (App.handleOpenInSplit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire tab/tile actions into the command palette and hotkey system.
- TilingHandle + TilingActionSet gain new-tab (Mod+T), close-tab,
next-tab (Mod+Alt+Right), prev-tab (Mod+Alt+Left). Close-tab has no
default binding since Mod+W is owned by the native window menu.
- tabsModel.cycleActiveTab: pure wrap-around tab cycling (+ tests).
useTilingDocumentSlots exposes cycleTab.
- tiling.tsx: the imperative handle now delegates to a per-render
handleImplRef, so every tiling/tab command runs against the latest
tree / activeTileId / tab state (previously only splits did).
- formatHotkeyForDisplay: render special keys as glyphs (arrows, Enter,
Tab, etc.) so the palette shows arrow icons, not "ARROWLEFT".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening a document no longer replaces the active tab. New pure helper
tabsModel.planOpen decides: focus an existing tab showing the same
document (no reopen), else reuse the active empty start tab, else append
a new tab. makeTabId is only called when a tab is actually created.
useTilingDocumentSlots routes opens through planOpen and skips
startDocumentOpen when the document was already open (focus only).
Also make the active-tab highlight switch instantly: tab chips opt out
of the global 320ms color transition (transition-none) so selecting a
tab updates the highlight immediately.
Fix tab switches sometimes showing the previous file: key DocumentPane
by document id in DocumentSlotView so switching to a different document
remounts a fresh editor. The CodeMirror view binds content on mount
only, and the in-place content swap was skippable by the isWriting
echo-suppression lock, so a reused editor could keep showing the old
file even with the new tab marked active.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tab strip UI and per-vault layout persistence on top of the phase-1 tab model.
- TileTabStrip: per-tile tab chips (select/close, middle-click, +). Empty
tabs are not chips — they render TileStartView, a quick-actions start
page whose only action is "New File" (wired to App.handleNewFile:
createFile + open). + reuses an existing empty tab (findEmptyTab).
- tiling.tsx: render the strip + active tab; close-the-last-tab collapses
the split via model.removeLeaf / planTileRemoval (pure, tested).
- Per-vault state: WorkspaceHost mounts one instance per (kind x vault)
and shows only the active one, preserving each vault's tile layout,
tabs, and open documents across vault/mode switches. Inactive instances
are gated by `active` (ignore opens, suppress bottom bar, no split
handle) via WorkspaceProps.active + barSlot WorkspaceActiveProvider.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Foundation for multiple tabs per tile in the tiling workspace. No UI
change yet; behavior is preserved.
- model.ts: add removeLeaf (collapse split into sibling; root removal is
a no-op) and listTileIds, for last-tab close.
- tabsModel.ts: pure tab-state model (TileTabsMap), React-free and
side-effect-free. closeTab reports removedTabIds + tileEmptied so the
hook owns handle release and the caller owns collapse-vs-reseed.
- useTilingDocumentSlots: re-back onto TileTabsMap, key DocumentHandles
by tab id, preserve getSlot/ensureTile, add getTabs/getActiveTabId/
setActiveTab/newTab/closeTab for the upcoming tab UI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- .githooks/pre-commit runs `format:check`, `lint`, and `test`; commits
are blocked unless all pass. Enabled via core.hooksPath, wired by a
`prepare` npm script so `npm install` activates it on fresh clones.
- Ran `prettier --write .` across the repo so it's now Prettier-clean and
the hook's format:check passes going forward.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Textile is now vault-only. Remove the standalone document/filesystem
providers and everything they kept alive:
Providers:
- documents: automerge, habitat, subtext
- filesystem: automerge, habitat, subtext, remoteSync (stub)
- App.tsx: drop staticProviders + isSubtextEnabled; the sidebar lists
only vault providers.
Yjs stack (only the removed providers produced 'yjs' bindings):
- editors/yjs/* + the registry 'yjs' entry; EditorKind is now 'automerge'.
- habitat/docSession.ts (+ test) and habitat/connectionProvider.ts;
libp2pNode now sources HabitatLibp2pNode from automergeConnectionProvider.
- habitatDoc.ts trimmed to the CRDT-agnostic URI helpers (parseDocUri,
editRkeyFor) the vault stack still uses.
- automergeDocSession: dropped the now-subscriber-less title-emit machinery.
Subtext subsystem (fully):
- src/subtext/*, the main-process SubtextTCPClient + IPC handlers, the
preload/vite-env bridge, and the Settings → Subtext pane + icon.
Dependencies orphaned by the above:
- yjs, y-protocols, @tiptap/* (the old TipTap/Yjs editor), protobufjs(+cli)
and the dead proto:gen script, it-byte-stream. Kept lib0 (used by the
automerge connection provider).
Net ~-9.4k lines of source. Lint/tsc/tests clean (56 passing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 4 (editor binding):
- Port pushwork's text-diff.ts verbatim (diffChars + Automerge.splice) so
vault file edits are character-level and merge cleanly — and the change
ops match pushwork for parity. Adds the `diff` dependency.
- Automerge editor reads/writes content via readDocContent/updateTextContent.
- Text-only guard (isTextFile): binary files don't open in the markdown
editor. Synced vault tabs title by filename.
Remote-vault sync-down:
- New `network.habitat.vault.root` collection for vault roots, so discovery
is a single unfiltered query. Unsynced remote vaults show in the sidebar
selector with a cloud-download icon; a "Sync Remote Vault" modal picks a
directory and runs the initial full clone (awaited, with progress).
Unified vault model:
- Collapse OpenVault + RemoteVaultRoot into a discriminated `Vault` union
(local | synced | remote-unsynced) behind a `VaultRegistry` singleton that
owns local persistence + remote discovery.
- Reduce VaultConfig to the engine's input view ({ type, rootUri }); derive
it via toVaultConfig() at the engine boundary.
Hashing:
- Replace Node `crypto` in content.ts with @noble/hashes (sync, browser+node)
so contentHash works in the Electron renderer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make synced vaults functional end-to-end by driving the push-only sync
engine from the providers and the composition root:
- SyncedVaultFilesystemProvider: disk tree + CRUD (extends
LocalFilesystemProvider), reconciling each mutation through the engine
(pushFile on create/copy, sync on rename/delete).
- SyncedVaultDocumentsProvider: opens files via the live Automerge session
(multiplayer over libp2p), resolving disk path -> Habitat URI from the
snapshot (pushing first if new), pulling latest on open, and mirroring
session edits back to disk. The session factory is injected (DI) so the
module stays free of the libp2p runtime and is testable.
- engine.ts: add noteMirroredFile() so mirrored in-app edits aren't
re-pushed as phantom external changes.
- registry.ts: persist OpenVault.rootUri.
- App.tsx: provision a synced vault's root directory record on first open,
construct the per-vault engine stack, run the first-mount sync(), and
wire the two synced providers (acquireSession = real acquireDocSession).
Testing uses fakes over mocks: extract InMemoryHabitatRepo +
NodeFilesystemApi to src/vault/testing/fakeHabitat.ts and add a
FakeDocSession sharing one in-memory doc store with the repo. The provider
test runs the real engine/detector/snapshot manager against the fakes and
asserts on real state (on-disk files, snapshot, doc store) — no vi.mock.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
loadAutomergeDoc captured entry.doc before awaiting the PDS fetch. A
concurrent applyChange during that await marked the captured reference
stale (Automerge progressDocument sets state.heads), so the resumed
loadIncremental threw "Attempting to change an out of date document".
Fix: load into a fresh Automerge.init({ actor }) and reconcile any
in-flight local changes after the await via Automerge.merge(result.doc,
entry.doc). Applied to initSession, reinitSession, and resyncEntry.
mergeAllEdits now returns the final doc reference (MergeEditsResult) so
loadAutomergeDoc threads through a valid (non-stale) doc.
Adds a TODO for a regression test, and TESTING.md documenting the
Vitest/Automerge WASM config.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the vault core sync algorithms on top of the phase 1 foundation:
- change-detection.ts: split detection into local-only (steady state,
no network) and remote-tree (first-open clone only). Removes the
per-file remote head-comparison.
- engine.ts: VaultSyncEngine. sync() does a one-time remote clone on
first mount, then is push-only. Adds per-file openFile() (pull on
open) and pushFile() (push on save), so steady-state cost is
proportional to what the user touched rather than the whole tree.
- move-detection.ts: rename detection by content similarity.
- habitat-repo.ts: implement create() via createAutomergeDoc.
- types.ts/snapshot.ts: supporting types and snapshot wiring.
- App.tsx: TODO to run a top-level sync on first vault mount.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract docSession module-level state (ydocRegistry, titleListeners,
visibilityListenerInstalled) into a DocSessionRegistry class. Tests now
instantiate a fresh registry per test instead of calling a test-only
reset function exported from production code. The module-level
acquireDocSession/subscribeEntryTitleChanges wrappers delegate to a
default singleton so no callers change.
Also fix Vitest picking @automerge/automerge's browser entry (base64
WASM, OOMs) by externalising the package and passing --conditions=node
to forked workers so Node picks the disk-loading entry instead.
Remove importOriginal() from habitatDocumentsProvider.test.ts to avoid
pulling in libp2p's native addon during mock setup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Lays the groundwork for Obsidian-style vaults backed by Habitat PDS +
libp2p gossipsub. Adds types, HabitatRepo/DocHandle adapter over
AutomergeDocSession, VaultSnapshotManager, vault registry, and utility
modules (content hashing, text diff, move detection similarity, directory
traversal). Also pulls forward collection-aware routing in automergeDoc.ts
and adds changeAt to AutomergeDocSession.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The cm-hr line decoration that draws the rule was applied unconditionally,
so placing the cursor on a horizontal rule showed both the rule and the raw
"---". Only style the line (and hide its glyphs) when the cursor is on a
different line; on the rule's own line, emit nothing so the raw "---" shows
for editing — matching how headings/blockquotes reveal their markers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`.cm-hr` is a line decoration on a `.cm-line`, and it used `margin: 1em 0`
for the rule's vertical spacing. A vertical margin on a line is not part of
the box CodeMirror measures for its height map, so each `---` added ~32px of
height that CM6 didn't account for — shifting the caret/click mapping for
every line below the rule downward, compounding with each additional rule.
This read as "clicking anywhere puts the cursor a couple lines too low."
Use padding (which is inside the measured box) for the spacing and draw the
rule with a centered pseudo-element instead of a top border.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reserve the CodeMirror scroller's scrollbar gutter with
`scrollbar-gutter: stable` so content no longer shifts left when a
document first grows tall enough to need a vertical scrollbar.
Add a non-passive wheel listener to the tile tab strip so a vertical
mouse wheel scrolls it horizontally while the pointer is over it
(trackpad horizontal gestures still work via the dominant axis).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pressing Enter after a code fence left the new line outside the block
background until the closing ``` was typed. The per-line loop decremented
endLine whenever node.to sat at a line start — but @lezer/markdown only
puts node.to at a line start for an *unterminated* block (the newline just
typed), never for a closed one (whose node ends on the closing-fence line).
So the decrement only ever dropped the active editing line. Remove it: the
loop now paints every line the node spans, keeping the cursor's line — and
the closing ``` — inside the block, while closed blocks still stop at the
fence line and never paint the blank line after.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cm-codeblock line decoration carried vertical margin, per-line
padding, and border-radius, so each code line rendered as its own rounded
box with the editor background showing through the gaps between lines.
Drop the vertical margin and give the block horizontal padding only, then
tag the first/last block lines (cm-codeblock-first / -last) to add the
vertical padding and rounded corners — the run now reads as one
continuous box.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cm-inline-code was applied only to the text between the backticks, so
when the markers were visible (cursor near) the highlight box stopped
short of them. Style the whole InlineCode node instead — backticks
included — matching the bold/italic content-mark pattern. When the
backticks are hidden they collapse to zero width inside the same box, so
the far-cursor appearance is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Proximity reveal used a 2-char window, so after typing a closing marker
(e.g. the second *) the markers stayed visible until the cursor moved 3
characters past them. Set PROXIMITY_CHARS to 0 so markers reveal only
while the cursor is within/touching the span and hide the moment it moves
one character past the closing marker. Affects bold, italic, strikethrough,
inline code, and links.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restructure the Automerge editor's single livePreview.ts into per-node
CodeMirror extensions composed by livePreview(), mirroring the reams
reference. Extends coverage from 4 node types to the core CommonMark +
GFM set (no widgets):
- headings: ported as-is
- bold / italic / strikethrough: content classes + proximity-revealed
markers, registered atomic so cursor motion skips hidden markers
- inline code + fenced code blocks: content/line styling + fence hiding
- links: fix the broken handler (hide all brackets/paren/URL, style the
label) + cmd/ctrl-click to open externally via a new shell.openExternal
IPC bridge
- blockquotes, horizontal rules, lists: line decorations + prefix hiding
Splits the single cm-formatting-hidden class into idiom-correct hide
classes (per the CM6 cursor-skip contract) and activates the previously
dead CSS. Adds headless builder tests (pure over EditorState) covering
each node type.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drag a file in the sidebar onto a directory (or the vault root) to move it.
- FileSystemProvider gains moveFile(entryId, targetDirId) and a rootId.
Local implementation renames across directories (guards same-location
and name conflicts); the synced provider syncs afterward like rename.
- VaultTree: files are draggable (independent off-screen drag ghost);
every row carries data-drop-dir for its enclosing folder, and the tree
container resolves the drop target via closest() — so it picks the
deepest folder under the cursor, falls through to the root over empty
space / root-level files, and clears reliably. The target directory's
whole subtree (or the whole tree, for the root) is highlighted.
- AppSidebar: handleMove calls provider.moveFile then refreshes the tree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Split Right / Split Down move from the bottom bar to the top bar as
icon buttons (SplitRightIcon / SplitDownIcon) with hover tooltips, left
of the workspace selector, shown only in tiling mode. Wired through the
tiling handle (App.handleSplitRight/Down).
- Title bar: compact fixed-height buttons (size-5) with header padding so
hover highlights no longer reach the screen edge; tooltip + pointer
cursor on the sidebar toggle, split buttons, and workspace menu.
- Sidebar: fix asymmetric collapse animation — the open state now also
transitions width, so re-opening animates instead of snapping.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drag tab chips to reorder, move between tiles, or split.
- tabsModel.moveTab: reorder within / move across tiles (pure + tested);
creates the destination tile if absent and reports sourceEmptied.
- tabDnd: shared drag payload + computeDropZone (5 zones; pure + tested).
- model.splitLeaf gains `before` so a drop on a left/top edge puts the
new tile first.
- tiling.tsx: TilingTile shows a translucent zone overlay (raised via a
capture overlay so the editor can't swallow drag events); dropping on
an edge splits the tile and moves the tab; center moves it in.
- TileTabStrip: draggable chips with an insertion marker, cleared on
dragleave/dragend; an independent off-screen clone is used as the drag
image so the clipped live chip can't corrupt it. Thin, non-overlay tab
scrollbar (TileTabStrip.css).
Fixes found while testing:
- White-screen crash: read currentTarget synchronously, not inside a
setState updater (React nulls it after the handler).
- Tiles overlapped: split panes now flex-grow to share the space the
fixed splitter leaves, so panes + divider sum to 100% with no
hardcoded splitter width.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each vault's tile layout and tabs now survive an app restart, along with
the selected vault and (pre-existing) workspace kind.
- tilingPersistence: serializeLayout / buildInitialTileTabs / maxIdSuffix
(pure + tested) and load/save to localStorage under
textile.tiling.<vaultId>. Persists the split tree, active tile, and
each tile's documents + active index; not handles or load state.
- useTilingDocumentSlots: accept initialTiles, seed the tab map with
loading tabs, and re-open the documents in a mount-only effect
(cancelled on unmount). Exposes tileTabs for serialization.
- TilingWorkspace: lazy-init tree/activeTile/id-counter from the
persisted layout (keyed by instanceKey = vault provider id) and write
on change (skipping no-op rewrites).
- App: persist + restore selectedProviderId (textile.selectedVault); the
registry loads vaults synchronously so the restored vault survives the
auto-select effects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tab management via right-click, a shared context-menu provider, and a
sidebar action to open a file in a new tile.
- tabsModel: closeOthers / closeToRight / closeToLeft (anchor survives)
and closeAllOfFile (across tiles, reports emptiedTileIds), all pure +
tested. Shared slotShowsRequest helper.
- TileTabStrip: right-click -> native context menu of close actions
(unified onTabAction dispatcher). useTilingDocumentSlots gains the
matching close methods; tiling.tsx collapses tiles emptied by
close-all-of-file.
- platform/contextMenu: ContextMenuProvider + useContextMenu inject the
context-menu capability (electron impl is the one place touching
window.textile). Provider mounted at the App composition root; TileTabStrip
and the sidebar consume via the hook instead of window.
- Sidebar file menu: "Open in new tile to the right/below" splits the
active tile then opens the file (App.handleOpenInSplit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire tab/tile actions into the command palette and hotkey system.
- TilingHandle + TilingActionSet gain new-tab (Mod+T), close-tab,
next-tab (Mod+Alt+Right), prev-tab (Mod+Alt+Left). Close-tab has no
default binding since Mod+W is owned by the native window menu.
- tabsModel.cycleActiveTab: pure wrap-around tab cycling (+ tests).
useTilingDocumentSlots exposes cycleTab.
- tiling.tsx: the imperative handle now delegates to a per-render
handleImplRef, so every tiling/tab command runs against the latest
tree / activeTileId / tab state (previously only splits did).
- formatHotkeyForDisplay: render special keys as glyphs (arrows, Enter,
Tab, etc.) so the palette shows arrow icons, not "ARROWLEFT".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening a document no longer replaces the active tab. New pure helper
tabsModel.planOpen decides: focus an existing tab showing the same
document (no reopen), else reuse the active empty start tab, else append
a new tab. makeTabId is only called when a tab is actually created.
useTilingDocumentSlots routes opens through planOpen and skips
startDocumentOpen when the document was already open (focus only).
Also make the active-tab highlight switch instantly: tab chips opt out
of the global 320ms color transition (transition-none) so selecting a
tab updates the highlight immediately.
Fix tab switches sometimes showing the previous file: key DocumentPane
by document id in DocumentSlotView so switching to a different document
remounts a fresh editor. The CodeMirror view binds content on mount
only, and the in-place content swap was skippable by the isWriting
echo-suppression lock, so a reused editor could keep showing the old
file even with the new tab marked active.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tab strip UI and per-vault layout persistence on top of the phase-1 tab model.
- TileTabStrip: per-tile tab chips (select/close, middle-click, +). Empty
tabs are not chips — they render TileStartView, a quick-actions start
page whose only action is "New File" (wired to App.handleNewFile:
createFile + open). + reuses an existing empty tab (findEmptyTab).
- tiling.tsx: render the strip + active tab; close-the-last-tab collapses
the split via model.removeLeaf / planTileRemoval (pure, tested).
- Per-vault state: WorkspaceHost mounts one instance per (kind x vault)
and shows only the active one, preserving each vault's tile layout,
tabs, and open documents across vault/mode switches. Inactive instances
are gated by `active` (ignore opens, suppress bottom bar, no split
handle) via WorkspaceProps.active + barSlot WorkspaceActiveProvider.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Foundation for multiple tabs per tile in the tiling workspace. No UI
change yet; behavior is preserved.
- model.ts: add removeLeaf (collapse split into sibling; root removal is
a no-op) and listTileIds, for last-tab close.
- tabsModel.ts: pure tab-state model (TileTabsMap), React-free and
side-effect-free. closeTab reports removedTabIds + tileEmptied so the
hook owns handle release and the caller owns collapse-vs-reseed.
- useTilingDocumentSlots: re-back onto TileTabsMap, key DocumentHandles
by tab id, preserve getSlot/ensureTile, add getTabs/getActiveTabId/
setActiveTab/newTab/closeTab for the upcoming tab UI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- .githooks/pre-commit runs `format:check`, `lint`, and `test`; commits
are blocked unless all pass. Enabled via core.hooksPath, wired by a
`prepare` npm script so `npm install` activates it on fresh clones.
- Ran `prettier --write .` across the repo so it's now Prettier-clean and
the hook's format:check passes going forward.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Textile is now vault-only. Remove the standalone document/filesystem
providers and everything they kept alive:
Providers:
- documents: automerge, habitat, subtext
- filesystem: automerge, habitat, subtext, remoteSync (stub)
- App.tsx: drop staticProviders + isSubtextEnabled; the sidebar lists
only vault providers.
Yjs stack (only the removed providers produced 'yjs' bindings):
- editors/yjs/* + the registry 'yjs' entry; EditorKind is now 'automerge'.
- habitat/docSession.ts (+ test) and habitat/connectionProvider.ts;
libp2pNode now sources HabitatLibp2pNode from automergeConnectionProvider.
- habitatDoc.ts trimmed to the CRDT-agnostic URI helpers (parseDocUri,
editRkeyFor) the vault stack still uses.
- automergeDocSession: dropped the now-subscriber-less title-emit machinery.
Subtext subsystem (fully):
- src/subtext/*, the main-process SubtextTCPClient + IPC handlers, the
preload/vite-env bridge, and the Settings → Subtext pane + icon.
Dependencies orphaned by the above:
- yjs, y-protocols, @tiptap/* (the old TipTap/Yjs editor), protobufjs(+cli)
and the dead proto:gen script, it-byte-stream. Kept lib0 (used by the
automerge connection provider).
Net ~-9.4k lines of source. Lint/tsc/tests clean (56 passing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 4 (editor binding):
- Port pushwork's text-diff.ts verbatim (diffChars + Automerge.splice) so
vault file edits are character-level and merge cleanly — and the change
ops match pushwork for parity. Adds the `diff` dependency.
- Automerge editor reads/writes content via readDocContent/updateTextContent.
- Text-only guard (isTextFile): binary files don't open in the markdown
editor. Synced vault tabs title by filename.
Remote-vault sync-down:
- New `network.habitat.vault.root` collection for vault roots, so discovery
is a single unfiltered query. Unsynced remote vaults show in the sidebar
selector with a cloud-download icon; a "Sync Remote Vault" modal picks a
directory and runs the initial full clone (awaited, with progress).
Unified vault model:
- Collapse OpenVault + RemoteVaultRoot into a discriminated `Vault` union
(local | synced | remote-unsynced) behind a `VaultRegistry` singleton that
owns local persistence + remote discovery.
- Reduce VaultConfig to the engine's input view ({ type, rootUri }); derive
it via toVaultConfig() at the engine boundary.
Hashing:
- Replace Node `crypto` in content.ts with @noble/hashes (sync, browser+node)
so contentHash works in the Electron renderer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make synced vaults functional end-to-end by driving the push-only sync
engine from the providers and the composition root:
- SyncedVaultFilesystemProvider: disk tree + CRUD (extends
LocalFilesystemProvider), reconciling each mutation through the engine
(pushFile on create/copy, sync on rename/delete).
- SyncedVaultDocumentsProvider: opens files via the live Automerge session
(multiplayer over libp2p), resolving disk path -> Habitat URI from the
snapshot (pushing first if new), pulling latest on open, and mirroring
session edits back to disk. The session factory is injected (DI) so the
module stays free of the libp2p runtime and is testable.
- engine.ts: add noteMirroredFile() so mirrored in-app edits aren't
re-pushed as phantom external changes.
- registry.ts: persist OpenVault.rootUri.
- App.tsx: provision a synced vault's root directory record on first open,
construct the per-vault engine stack, run the first-mount sync(), and
wire the two synced providers (acquireSession = real acquireDocSession).
Testing uses fakes over mocks: extract InMemoryHabitatRepo +
NodeFilesystemApi to src/vault/testing/fakeHabitat.ts and add a
FakeDocSession sharing one in-memory doc store with the repo. The provider
test runs the real engine/detector/snapshot manager against the fakes and
asserts on real state (on-disk files, snapshot, doc store) — no vi.mock.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
loadAutomergeDoc captured entry.doc before awaiting the PDS fetch. A
concurrent applyChange during that await marked the captured reference
stale (Automerge progressDocument sets state.heads), so the resumed
loadIncremental threw "Attempting to change an out of date document".
Fix: load into a fresh Automerge.init({ actor }) and reconcile any
in-flight local changes after the await via Automerge.merge(result.doc,
entry.doc). Applied to initSession, reinitSession, and resyncEntry.
mergeAllEdits now returns the final doc reference (MergeEditsResult) so
loadAutomergeDoc threads through a valid (non-stale) doc.
Adds a TODO for a regression test, and TESTING.md documenting the
Vitest/Automerge WASM config.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the vault core sync algorithms on top of the phase 1 foundation:
- change-detection.ts: split detection into local-only (steady state,
no network) and remote-tree (first-open clone only). Removes the
per-file remote head-comparison.
- engine.ts: VaultSyncEngine. sync() does a one-time remote clone on
first mount, then is push-only. Adds per-file openFile() (pull on
open) and pushFile() (push on save), so steady-state cost is
proportional to what the user touched rather than the whole tree.
- move-detection.ts: rename detection by content similarity.
- habitat-repo.ts: implement create() via createAutomergeDoc.
- types.ts/snapshot.ts: supporting types and snapshot wiring.
- App.tsx: TODO to run a top-level sync on first vault mount.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract docSession module-level state (ydocRegistry, titleListeners,
visibilityListenerInstalled) into a DocSessionRegistry class. Tests now
instantiate a fresh registry per test instead of calling a test-only
reset function exported from production code. The module-level
acquireDocSession/subscribeEntryTitleChanges wrappers delegate to a
default singleton so no callers change.
Also fix Vitest picking @automerge/automerge's browser entry (base64
WASM, OOMs) by externalising the package and passing --conditions=node
to forked workers so Node picks the disk-loading entry instead.
Remove importOriginal() from habitatDocumentsProvider.test.ts to avoid
pulling in libp2p's native addon during mock setup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Lays the groundwork for Obsidian-style vaults backed by Habitat PDS +
libp2p gossipsub. Adds types, HabitatRepo/DocHandle adapter over
AutomergeDocSession, VaultSnapshotManager, vault registry, and utility
modules (content hashing, text diff, move detection similarity, directory
traversal). Also pulls forward collection-aware routing in automergeDoc.ts
and adds changeAt to AutomergeDocSession.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>