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