From c1f500c9545f92bcdfb30e31ecd9d514fd16d96c Mon Sep 17 00:00:00 2001 From: Gadzhi Gadzhiev <168296+resure@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:11:19 +0300 Subject: [PATCH 1/2] fix: decouple the divider's unmount-commit from the onCommit prop identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mid-drag-unmount cleanup added in #15 tears down the live gesture (it nulls `drag`), so having `onCommit` in its dep list made correctness depend on the caller passing a referentially stable callback. Workspace happens to pass useState setters, so this is latent — but an inline lambda anywhere would break dragging on the next parent render: a premature commit, every later pointermove ignored, and `panel-resizing` stuck on (endDrag returns early on a null gesture, so `dragging` never clears) leaving the whole app in col-resize/no-select. Read onCommit through a ref and key the effect on [dragging] alone. Regression test asserts a parent re-render mid-drag leaves the gesture intact; it fails on the old code at the premature-commit assertion. Also two comment corrections and a docs catch-up: - The cleanup's "⌃R workspace switch" example was inert: App keys Workspace by workspace id, so a switch unmounts the whole thing — the .workspace element (and the inline var) goes with it, and the commit lands on an unmounting component, so nothing persists and nothing needed to. ⌘⇧\ closing the rail is the real case. - Note that the pointer path freezes its editor-room floor for the gesture while the keyboard path re-tightens per keypress, so the two disagree after shrinking past the cap and coming back. - CLAUDE.md: list the two panel-width keys with the other per-workspace layout keys, and give PanelResizer an entry in the components map. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 9 +++++-- src/components/PanelResizer.test.tsx | 40 ++++++++++++++++++++++++++++ src/components/PanelResizer.tsx | 30 ++++++++++++++------- 3 files changed, 68 insertions(+), 11 deletions(-) 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/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, From b7852deaa3ffcfcc0038b25fa201d4cafac18967 Mon Sep 17 00:00:00 2001 From: Gadzhi Gadzhiev <168296+resure@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:15:23 +0300 Subject: [PATCH 2/2] style: cargo fmt src-tauri (unbreak the rust CI job) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo fmt --check` has been red on main since the iOS work landed: the two cfg-gated `MetadataExt` imports in `is_dataless` are out of alphabetical order, which put the rust job in a failing state and so skipped its `cargo test` and `cargo clippy` steps entirely. Pure `cargo fmt` output; the two imports are mutually exclusive by cfg, so the order carries no meaning. Locally: fmt clean, 25 Rust tests pass, clippy --all-targets -D warnings clean (macOS host — CI builds Linux, where the macOS-only paths are cfg'd out). --- src-tauri/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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