Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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**
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<sys/stat.h>` — 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
Expand Down
40 changes: 40 additions & 0 deletions src/components/PanelResizer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {useState} from 'react';

import {fireEvent, screen} from '@testing-library/react';
import {describe, expect, it, vi} from 'vitest';

Expand Down Expand Up @@ -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 (
<>
<button type="button" onClick={() => setTick(tick + 1)}>
rerender
</button>
<PanelResizer
label="Resize note list"
width={280}
onResize={vi.fn()}
// Deliberately a new identity every parent render.
onCommit={(w) => onCommit(w)}
onReset={vi.fn()}
/>
</>
);
}
renderWithProviders(<Parent />);
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');
});
});
30 changes: 21 additions & 9 deletions src/components/PanelResizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,22 +86,31 @@ 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);
return () => {
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<HTMLDivElement>) => {
if (e.button !== 0) return;
Expand All @@ -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,
Expand Down
Loading