this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Harden editor title: strip .md on commit, survive concurrent renames, track peers

Three related fixes to the markdown editor's title handling.

MarkdownEditor.commitTitle used to unconditionally append ".md" to whatever
the user typed, so a paste of "foo.md" became "foo.md.md". Strip the
extension before comparing and re-appending.

EditorView.handleRename applied optimistic updates with a captured
"previous" value. Two renames in quick succession where the first fails
after the second has already applied would roll the title back past the
second rename. A generation counter filters out stale rollbacks — only
the most recent call's failure is allowed to undo its own optimistic write.

useDocumentContent was one-shot, so a peer renaming a document elsewhere
never flowed into the title input here. Subscribe to the parent directory's
metadata via useDirectoryMetadata (routes now pass parentDirectoryUri) and
sync displayName when peerName changes. MarkdownEditor already refuses to
clobber the user's in-flight keystrokes via a document.activeElement check,
so the sync is safe while editing.

documentDirectoryPathSuffix had no remaining callers after the route
rewrite; drop it.

+46 -28
+35 -10
apps/web/src/components/cabinet/EditorView.tsx
··· 13 13 14 14 import { useCallback, useEffect, useRef, useState } from "react"; 15 15 import { useBlocker, useNavigate } from "@tanstack/react-router"; 16 - import { useFileManager } from "@opake/react"; 16 + import { useDirectoryMetadata, useFileManager } from "@opake/react"; 17 17 import { MarkdownEditor } from "./MarkdownEditor"; 18 18 import { PanelShell } from "./PanelShell"; 19 19 import { toastError, toastSuccess } from "@/stores/toast"; ··· 30 30 readonly documentUri: string; 31 31 readonly context: FileContext; 32 32 readonly returnPath: string; 33 + /** 34 + * Parent directory URI, if known. Used to subscribe to metadata updates 35 + * so peer renames flow into the title input. Omit to disable the sync. 36 + */ 37 + readonly parentDirectoryUri?: string | null; 33 38 } 34 39 35 40 interface EditorViewNewProps { ··· 113 118 114 119 const documentUri = mode === "edit" ? props.documentUri : null; 115 120 const directoryUri = mode === "new" ? props.directoryUri : undefined; 121 + const parentDirectoryUri = mode === "edit" ? props.parentDirectoryUri ?? null : null; 116 122 const persistedUri = createdUri ?? documentUri; 117 123 118 124 const { loaded, error } = useDocumentContent(fm, documentUri); 119 125 120 - // Sync displayName with the loaded document's name in edit mode. 121 - // The load is async and external (IndexedDB / PDS round-trip), so 122 - // this is the "sync external data into React state" case the rule 123 - // explicitly allows; suppress since ESLint can't see through 124 - // useDocumentContent's boundary. 126 + // Subscribe to the parent directory's metadata so peer renames propagate 127 + // into the title input. MarkdownEditor won't clobber the user's keystrokes 128 + // while they're editing — it checks document.activeElement before syncing. 129 + const { data: directoryMetadata } = useDirectoryMetadata( 130 + keyringUriFor(context), 131 + parentDirectoryUri, 132 + ); 133 + const peerName = documentUri ? directoryMetadata?.[documentUri]?.name : undefined; 134 + 135 + // Sync displayName with the loaded document's name in edit mode. Two async 136 + // sources feed in: the initial fm.download() decrypt (loaded.documentName) 137 + // and live metadata updates from SSE (peerName via useDirectoryMetadata). 138 + // The eslint rule flags this as set-state-in-effect, but both sources are 139 + // external to React — the explicit allowance applies. 125 140 useEffect(() => { 126 - // eslint-disable-next-line react-hooks/set-state-in-effect -- external async source, see comment 127 - if (loaded?.documentName) setDisplayName(loaded.documentName); 128 - }, [loaded]); 141 + // eslint-disable-next-line react-hooks/set-state-in-effect -- external async sources, see comment 142 + if (peerName) setDisplayName(peerName); 143 + // eslint-disable-next-line react-hooks/set-state-in-effect -- external async sources, see comment 144 + else if (loaded?.documentName) setDisplayName(loaded.documentName); 145 + }, [loaded, peerName]); 129 146 130 147 // Block in-app navigation when there are unsaved changes. 131 148 // TanStack Router's useBlocker covers SPA navigations that ··· 145 162 const handleDirtyChange = useCallback((isDirty: boolean) => { 146 163 setDirty(isDirty); 147 164 }, []); 165 + 166 + // Generation counter so a failing rename doesn't clobber a newer rename 167 + // that landed while the failure was in flight. Rollback is skipped if this 168 + // call's generation is no longer the most recent. 169 + const renameGenRef = useRef(0); 148 170 149 171 const handleRename = useCallback( 150 172 (newName: string) => { ··· 153 175 // back on error. For "new" mode before the first save, there's 154 176 // nothing to persist yet; the name is applied on upload. 155 177 const previous = displayName; 178 + const gen = ++renameGenRef.current; 156 179 setDisplayName(newName); 157 180 if (!fm || !persistedUri) return; 158 181 fm.updateMetadata(persistedUri, { filename: newName }) 159 182 .then(() => toastSuccess("Renamed")) 160 183 .catch((err: unknown) => { 161 - setDisplayName(previous); 184 + if (renameGenRef.current === gen) { 185 + setDisplayName(previous); 186 + } 162 187 toastError(err instanceof Error ? err.message : "Rename failed"); 163 188 }); 164 189 },
+3 -1
apps/web/src/components/cabinet/MarkdownEditor.tsx
··· 345 345 346 346 const commitTitle = useCallback(() => { 347 347 if (!onRename) return; 348 - const trimmed = titleDraft.trim(); 348 + // Strip trailing .md before comparing and before re-appending — a user who 349 + // types the full "foo.md" would otherwise produce "foo.md.md". 350 + const trimmed = stripMdExtension(titleDraft.trim()); 349 351 const current = stripMdExtension(documentName); 350 352 if (!trimmed) { 351 353 // Empty input: revert to the current name.
-13
apps/web/src/lib/directoryTree.ts
··· 63 63 } 64 64 65 65 /** 66 - * Build a URL path suffix for the parent directory of a document. Used by 67 - * editor routes to compute a return path that drops the user back in the 68 - * directory they came from instead of the root. 69 - */ 70 - export function documentDirectoryPathSuffix( 71 - snapshot: DirectoryTreeSnapshot, 72 - documentUri: string, 73 - ): string | null { 74 - const parentUri = findParentUri(snapshot, documentUri); 75 - return parentUri ? directoryPathSuffix(snapshot, parentUri) : null; 76 - } 77 - 78 - /** 79 66 * Resolve a chain of rkey path segments to a directory URI by walking 80 67 * the tree from the root. Returns null if any segment doesn't match. 81 68 *
+4 -2
apps/web/src/routes/cabinet/editor/$rkey.lazy.tsx
··· 3 3 import { EditorView } from "@/components/cabinet/EditorView"; 4 4 import { useAuthStore } from "@/stores/auth"; 5 5 import { documentUri } from "@/lib/atUri"; 6 - import { documentDirectoryPathSuffix } from "@/lib/directoryTree"; 6 + import { directoryPathSuffix, findParentUri } from "@/lib/directoryTree"; 7 7 8 8 function Editor() { 9 9 const { rkey } = Route.useParams(); ··· 16 16 // Reactive tree read so the return path updates if the user arrives via 17 17 // direct URL before the tree has decrypted (e.g. deep link into an editor). 18 18 const { snapshot } = useDirectory(null, null); 19 - const pathSuffix = uri && snapshot ? documentDirectoryPathSuffix(snapshot, uri) : null; 19 + const parentUri = uri && snapshot ? findParentUri(snapshot, uri) : null; 20 + const pathSuffix = parentUri && snapshot ? directoryPathSuffix(snapshot, parentUri) : null; 20 21 const returnPath = pathSuffix ? `/cabinet/files/${pathSuffix}` : "/cabinet/files"; 21 22 22 23 if (!did || !uri) { ··· 33 34 documentUri={uri} 34 35 context={{ kind: "cabinet" }} 35 36 returnPath={returnPath} 37 + parentDirectoryUri={parentUri} 36 38 /> 37 39 ); 38 40 }
+4 -2
apps/web/src/routes/cabinet/workspace-editor/$rkey/$docRkey.lazy.tsx
··· 3 3 import { EditorView } from "@/components/cabinet/EditorView"; 4 4 import { useAuthStore } from "@/stores/auth"; 5 5 import { documentUri, rkeyFromUri } from "@/lib/atUri"; 6 - import { documentDirectoryPathSuffix } from "@/lib/directoryTree"; 6 + import { directoryPathSuffix, findParentUri } from "@/lib/directoryTree"; 7 7 8 8 function WorkspaceEditor() { 9 9 const { rkey, docRkey } = Route.useParams(); ··· 15 15 // even when the workspace isn't resolved yet. Guard rendering below. 16 16 const uri = did ? documentUri(did, docRkey) : null; 17 17 const { snapshot } = useDirectory(workspace?.uri ?? null, null); 18 - const pathSuffix = uri && snapshot ? documentDirectoryPathSuffix(snapshot, uri) : null; 18 + const parentUri = uri && snapshot ? findParentUri(snapshot, uri) : null; 19 + const pathSuffix = parentUri && snapshot ? directoryPathSuffix(snapshot, parentUri) : null; 19 20 const returnPath = pathSuffix 20 21 ? `/cabinet/workspace/${rkey}/${pathSuffix}` 21 22 : `/cabinet/workspace/${rkey}`; ··· 36 37 documentUri={uri} 37 38 context={{ kind: "workspace", keyringUri: workspace.uri }} 38 39 returnPath={returnPath} 40 + parentDirectoryUri={parentUri} 39 41 /> 40 42 ); 41 43 }