diff --git a/CLAUDE.md b/CLAUDE.md index 24263ab..945db29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,8 @@ new window). Desktop also has **per-note windows** (Apple-Notes-style): ⌘↵ / its ⋯ menu opens that note in its own `note-N` window — both side panels closed, focus in the editor body, native title = the note title, per-note focus-if-open — and **⌘0** (Window ▸ Main Window, a native accelerator) surfaces the full workspace window for THAT window's workspace, un-hiding or -creating one as needed. Per-workspace UI layout (sidebar/rail/selected folder) lives under +creating one as needed. Per-workspace UI layout (sidebar/rail/selected folder, plus the two +divider-dragged panel widths `rail-width`/`sidebar-width` — see `PanelResizer`) lives under workspace-namespaced localStorage keys — note windows neither read nor write those (their transient layout must not clobber the full views', and reading would eat the legacy-key migration); the sidecar metadata is per-folder anyway. @@ -354,7 +355,11 @@ Key modules: in-browser row is offered on the web only — an existing in-app registry entry still lists so no data strands), `FolderRail` (collapsible nested-folder tree left of the list — select/scope, - drag-and-drop, rename, pin; toggle ⌘⇧\), `NoteList` (sidebar with create/rename/delete/move, pin, + drag-and-drop, rename, pin; toggle ⌘⇧\), `PanelResizer` (the WAI-ARIA window-splitter divider on + the rail's and the list's right edge — gesture state in a ref so pointer moves write the width + CSS var straight to the DOM instead of re-rendering the editor, state committing only on release; + rendered only in the desktop column layout, since mobile makes the rail a drawer and stretches + the list to full width), `NoteList` (sidebar with create/rename/delete/move, pin, sort, **Open in New Window** (row ⋯/context menu, ⌘↵, ⌘-click — desktop only, gated by the optional `onOpenInNewWindow` prop); right-click and ⌘-click deliberately NEVER move the selection (the menu acts via its own payload, and a row `onMouseDown` blocks the focus grab); a **folder-scope chip** diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6bc0bd5..4cec9db 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -172,10 +172,10 @@ fn is_dataless(meta: &fs::Metadata) -> bool { // the platform-specific `MetadataExt` differs only by module. On iOS an evicted file is // materialized on open by the icloud-fs plugin's coordinated read (`read_note`), which triggers // the download; the walks below still skip its content so the list/corpus don't stall. - #[cfg(target_os = "macos")] - use std::os::macos::fs::MetadataExt; #[cfg(target_os = "ios")] use std::os::ios::fs::MetadataExt; + #[cfg(target_os = "macos")] + use std::os::macos::fs::MetadataExt; // SF_DATALESS ("file is dataless object") from `` — a super-user/system flag in the // high half of `st_flags`, defined there as `0x40000000`. Hand-coded because libc doesn't expose // it. Verified against the macOS 26.5 SDK header; if a future SDK ever moves it, the worst case diff --git a/src/components/PanelResizer.test.tsx b/src/components/PanelResizer.test.tsx index 403026d..8e562dc 100644 --- a/src/components/PanelResizer.test.tsx +++ b/src/components/PanelResizer.test.tsx @@ -1,3 +1,5 @@ +import {useState} from 'react'; + import {fireEvent, screen} from '@testing-library/react'; import {describe, expect, it, vi} from 'vitest'; @@ -180,4 +182,42 @@ describe('PanelResizer', () => { expect(onCommit).toHaveBeenCalledWith(320); expect(document.body).not.toHaveClass('panel-resizing'); }); + + it('survives a parent re-render mid-drag even with an unstable onCommit', () => { + // The unmount cleanup tears down the live gesture, so it must key on `dragging` ALONE. + // Naming onCommit as a dep instead makes an inline-lambda caller lose the drag on any + // parent render: premature commit, later moves ignored, and the body class stuck on + // (endDrag returns early, so `dragging` never clears) — the whole app left in col-resize. + const onCommit = vi.fn(); + function Parent() { + const [tick, setTick] = useState(0); + return ( + <> + + onCommit(w)} + onReset={vi.fn()} + /> + + ); + } + renderWithProviders(); + const divider = screen.getByRole('separator', {name: 'Resize note list'}); + fireEvent.pointerDown(divider, {button: 0, clientX: 100, pointerId: 1}); + fireEvent.pointerMove(divider, {clientX: 140, pointerId: 1}); + fireEvent.click(screen.getByRole('button', {name: 'rerender'})); + expect(onCommit).not.toHaveBeenCalled(); + // The gesture is still live: this move lands, and the release commits it exactly once. + fireEvent.pointerMove(divider, {clientX: 200, pointerId: 1}); + fireEvent.pointerUp(divider, {pointerId: 1}); + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledWith(380); + expect(document.body).not.toHaveClass('panel-resizing'); + }); }); diff --git a/src/components/PanelResizer.tsx b/src/components/PanelResizer.tsx index 77c1734..f0c832b 100644 --- a/src/components/PanelResizer.tsx +++ b/src/components/PanelResizer.tsx @@ -86,12 +86,21 @@ export function PanelResizer({ [getMaxWidth], ); - // The body class and a pending commit outlive the component only if it unmounts mid-drag - // (⌘⇧\ closes the rail, a ⌃R workspace switch): no pointerup will arrive, so the cleanup - // both drops the class and commits the width the DOM already shows — otherwise the inline - // var written during the drag survives with no matching state until a reload. On a normal - // release endDrag has already nulled the gesture, so the cleanup commit is a no-op. - // (onCommit is a useState setter in practice — stable, so this effect runs on drag edges.) + // Read through a ref so the cleanup below can stay on [dragging] alone. Naming onCommit as a + // dep would make the cleanup fire on any identity change — and it TEARS DOWN A LIVE GESTURE + // (nulls `drag`), so a caller passing an inline lambda would break dragging the moment its + // parent re-rendered: a premature commit, the rest of the gesture ignored, and — because + // endDrag then returns early and never clears `dragging` — the body class stuck on, leaving + // the whole app in col-resize/no-select. Today's callers happen to pass useState setters; + // that must not be load-bearing. + const onCommitRef = useRef(onCommit); + onCommitRef.current = onCommit; + + // The body class and a pending commit outlive the component only if it unmounts mid-drag — + // ⌘⇧\ closing the rail under a held divider is the real case (Workspace stays mounted, so + // the inline var it wrote survives with no matching state until a reload). No pointerup will + // arrive, so the cleanup both drops the class and commits the width the DOM already shows. + // On a normal release endDrag has already nulled the gesture, so the commit is a no-op. useEffect(() => { if (!dragging) return undefined; document.body.classList.add(DRAGGING_BODY_CLASS); @@ -99,9 +108,9 @@ export function PanelResizer({ document.body.classList.remove(DRAGGING_BODY_CLASS); const pending = drag.current; drag.current = null; - if (pending && pending.last !== pending.startWidth) onCommit(pending.last); + if (pending && pending.last !== pending.startWidth) onCommitRef.current(pending.last); }; - }, [dragging, onCommit]); + }, [dragging]); const handlePointerDown = (e: ReactPointerEvent) => { if (e.button !== 0) return; @@ -113,7 +122,10 @@ export function PanelResizer({ e.currentTarget.setPointerCapture?.(e.pointerId); // The editor-room cap stops GROWTH; it must never pull an already-wider panel back on the // first move (a small window would otherwise snap the panel to the cap regardless of drag - // direction), so the current width always floors it. + // direction), so the current width always floors it. Frozen for the gesture, unlike the + // keyboard path's per-keypress floor: shrinking past the cap mid-drag and coming back can + // end above it, but never past where the drag started — a drag returning to its own + // starting width is the one place a rubber-band feels worse than honoring the cap. drag.current = { startX: e.clientX, startWidth: width,