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
11 changes: 11 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5273", "--strictPort"],
"port": 5273
}
]
}
36 changes: 27 additions & 9 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,29 @@ build, or a "this looks wrong" after actually trying the app, leaves only throwa
(`~/.cargo/bin/cargo --version` ≥ 1.88 — the Homebrew rust 1.87 is too old; see the
`rust-toolchain-tauri` memory). The build script prepends `~/.cargo/bin` to PATH itself, so a
Homebrew cargo sitting first on PATH no longer breaks the build.
3. **Signing credentials** in the environment (the build script reads these; never print their
values): the Apple set — `APPLE_SIGNING_IDENTITY`, `APPLE_API_KEY` (or `APPLE_API_KEY_ID`, which
the build script aliases to it), `APPLE_API_ISSUER`, `APPLE_API_KEY_PATH` (the `.p8` file is
readable) — **and** the updater key
`TAURI_SIGNING_PRIVATE_KEY` (path to / content of the passwordless
`~/Documents/Apple Connect Keys/gravity-notes-updater.key`;
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` may be empty). Check each is set — without the updater key
the build can't sign the auto-update bundle and fails.
3. **Signing credentials** — these live in **`~/.localrc`**, NOT in the agent's default
environment. The Bash tool starts a **fresh shell on every call** and does not persist env vars
between calls, so you must `source ~/.localrc` **in the same command** as anything that reads the
credentials — both this check **and** the build in Step 3 (a bare `source` in an earlier call is
gone by the next). The vars (the build script reads these; **never print their values**): the
Apple set — `APPLE_SIGNING_IDENTITY`, `APPLE_API_KEY` (or `APPLE_API_KEY_ID`, which the build
script aliases to it), `APPLE_API_ISSUER`, `APPLE_API_KEY_PATH` (the `.p8` file is readable) —
**and** the updater key `TAURI_SIGNING_PRIVATE_KEY` (path to / content of the passwordless
`~/Documents/Apple Connect Keys/gravity-notes-updater.key`; `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`
may be empty). Verify each is set (names only, never values) — without the updater key the build
can't sign the auto-update bundle and fails:

```bash
# The Bash tool runs zsh, so this stays POSIX (no `${!v}` indirect expansion — that's a bash-ism).
test -f ~/.localrc || echo "MISSING ~/.localrc — signing creds live here"
source ~/.localrc
[ -n "$APPLE_SIGNING_IDENTITY" ] || echo "MISSING: APPLE_SIGNING_IDENTITY"
[ -n "$APPLE_API_ISSUER" ] || echo "MISSING: APPLE_API_ISSUER"
[ -n "$TAURI_SIGNING_PRIVATE_KEY" ] || echo "MISSING: TAURI_SIGNING_PRIVATE_KEY"
[ -n "$APPLE_API_KEY" ] || [ -n "$APPLE_API_KEY_ID" ] || echo "MISSING: APPLE_API_KEY"
[ -r "$APPLE_API_KEY_PATH" ] || echo "MISSING/unreadable: APPLE_API_KEY_PATH"
```

4. **GitHub CLI.** `gh auth status` is logged in.
5. **Green tree.** Run `npm run typecheck`, `npm test`, and `npm run lint`. Do not
release a red tree.
Expand Down Expand Up @@ -83,8 +98,11 @@ lockstep (leave them **uncommitted** for now). Capture `$NEW` — every later st

## Step 3 — Build, sign, notarize (the slow step)

**Source the credentials in the same command as the build** — the Bash tool's shell doesn't carry
env vars over from the Step 0 check (fresh shell per call), so the build needs its own `source`:

```bash
./scripts/build-mac-release.sh
source ~/.localrc && ./scripts/build-mac-release.sh
```

Tauri builds + signs the `.app` (hardened runtime, Developer ID) and notarizes+staples
Expand Down
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,12 @@ in-browser storage. The desktop app reads the folder natively, with no re-prompt
- Restore all workspace windows on relaunch (today only the last-active one comes back)

- Resizable left panel
- Icon picker polish: `aria-activedescendant` can point at a virtualized-out option; the title picker can still set an icon in preview mode; no search debounce

- Preserve cmd+z between notes
- Cmd+z for undoing deleting of notes and moves between folders?

- Easter egg in top bar (to the right of the search bar) ?
- Notion-like page title backgrounds
- Better looking checklists

- Auto-empty the Trash and clean unused attachments (after 30 days?)
- Metadata storage approach review
Expand Down
113 changes: 113 additions & 0 deletions docs/proposals/preserve-undo-across-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Proposal: Preserve undo/redo across note switches

Status: **proposed** (not yet implemented) · Backlog item: "Preserve cmd+z between notes"

## Context

Today, switching notes **hard-resets** both undo histories (ProseMirror + CodeMirror) so `⌘Z`
can't walk into the previous note's content. The cost: edit note A, switch to B, come back to A —
`⌘Z` does nothing, A's edit history is gone. This proposal makes per-note undo/redo **survive**
switching away and back, within a session.

The editor (`@gravity-ui/markdown-editor`) is a single reused instance; content is swapped in place
per note. There is **no** public API to serialize/restore history (the `history` plugin instance is
private, so `EditorState.toJSON`/`fromJSON` with plugin fields is a dead end). The workable approach:
**hold each note's live `EditorState` object in memory and restore it wholesale** — history travels
with the object. This is exactly what the current reset already does, just with a _fresh_ state:
`view.updateState(EditorState.create({doc, plugins}))` (`EditorPane.tsx`, `resetHistory`). We swap the
fresh state for the saved one.

All changes are contained to `src/components/EditorPane.tsx` (the swap logic), with doc updates.

## Approach

Keep two per-note maps of live editor states (one per mode), save the **active** mode's state when
switching away, and restore it when returning — but only if it's still valid.

### Why active-mode-only

The hidden mode's buffer goes stale between mode toggles (e.g. editing in WYSIWYG leaves the
CodeMirror buffer showing old content until a toggle regenerates it). So its saved state wouldn't
match `editor.getValue()`. Saving/restoring **only the currently-active mode** (`editor.currentMode`)
keeps the stored content in lockstep with the saved doc. The inactive mode keeps today's fresh-reset
behavior (which also re-syncs its hidden buffer to the new content — load-bearing, see the
`resetMarkupHistory` comment in `EditorPane.tsx`).

### Validity guard

A saved state is only restored when its stored `content === note.content` (the incoming, on-disk
content). Pending edits are flushed before `open()`, so on a normal switch-back these match. If the
note changed on disk while away (external edit / conflict reload), they won't — fall back to a fresh
reset so we never restore stale history over new content.

### Memory footprint

A saved `EditorState` is dominated by its immutable document tree (roughly 2–5× the note's text size
in JS object overhead) plus the `history` plugin's stack (`history({depth: 100})` — bounded; small
for normal typing). Typical note → ~20–100 KB; a large 100 KB note → a few hundred KB. Both maps are
**LRU-capped at ~25 notes**, so the worst case (25 huge, heavily-edited notes) is the only place this
grows, and the cap bounds it. The cap value is the effective lever if the footprint needs tuning.

## Changes — `src/components/EditorPane.tsx`

1. **Two LRU-capped ref maps** beside the existing `viewStateByIdRef`:
- `pmHistByIdRef: Map<string, {state: EditorState; content: string}>`
- `cmHistByIdRef: Map<string, {state: CmEditorState; content: string}>`
- Cap ~25 entries each (delete-then-set for LRU ordering; evict oldest past the cap).

2. **Extend `saveViewState(id)`** to also capture the active mode's live state with the current
content:
- `wysiwyg` → `pmHistByIdRef.set(id, {state: wikiViewRef.current.state, content: editor.getValue()})`
- `markup` → `cmHistByIdRef.set(id, {state: markupEditorOf(editor).cm.state, content: editor.getValue()})`

3. **Turn the two resets into restore-or-reset** (return `preserved: boolean`):
- `resetHistory(id)`: if `currentMode === 'wysiwyg'` and a saved PM entry's
`content === note.content` → `view.updateState(saved.state)` and return `true`. Else the current
fresh `EditorState.create({doc, plugins})` path, return `false`.
- `resetMarkupHistory(id)`: symmetric with `markup.cm.setState(saved.state)` vs the current
`freshMarkupState(template, note.content)`.

4. **Swap effect**: pass `note.id` into both resets; capture `preserved` from `resetHistory`. A
restored PM state already carries its own selection, so **skip `restoreSelection`** when preserved
(calling it would dispatch a redundant `setSelection` — which would pollute the just-restored
history). Keep it for the fresh path.

5. **Re-key on rename/move**: carry `pmHistByIdRef` / `cmHistByIdRef` entries from the old id to the
new one, alongside the existing `viewStateByIdRef` re-key.

Reused as-is: `markupEditorOf` / `freshMarkupState` (`editor/markupHistory.ts`), `editor.currentMode`
(already used in `EditorPane.tsx`), the `swappingRef` bracket (updateState/setState run inside it, so
the change handler still treats them as a load echo, not a user edit).

## Docs

- Update the class comment in `EditorPane.tsx` (the "history HARD-RESET" note) and the
`resetHistory`/`resetMarkupHistory` doc comments to describe restore-or-reset.
- Update `CLAUDE.md` (the EditorPane line stating a note switch "hard-resets BOTH undo histories").

## Verification

**Live (primary), via the preview dev server:**

1. WYSIWYG: type in note A, switch to B, type in B, switch back to A → `⌘Z` undoes A's edits (not
B's, not nothing); `⌘⇧Z` redoes. Switch to B → `⌘Z` undoes B's. Confirm undo stops at each note's
loaded state and never crosses into the other note's text.
2. Markup mode (`⌘⇧;`): repeat — history preserved per note there too.
3. Fresh open of a never-visited note → `⌘Z` is a no-op (nothing to undo). Rename a note mid-session,
switch away and back → history still restores under the new name.

**Tests:** add coverage where deterministic in jsdom — extend the editor-history tests
(`editor/markupHistory.test.ts` is the closest existing harness) and/or add an EditorPane/Workspace
integration test that types in A, switches to B and back, dispatches undo, and asserts A's content
reverts. Full suite (`npm test`), `npm run typecheck`, `npm run lint`, `npm run format:check`.

## Risks & mitigations

- **Restoring a bad/stale state** → guarded by content-match + active-mode checks; any miss falls
back to today's fresh reset (the existing, safe behavior).
- **Memory growth** → both maps LRU-capped (~25 notes).
- **Cross-mode / conflict-reload edge cases** → the content-match guard makes these fall back to
fresh reset, matching current behavior.
- This is the app's most delicate code (the swap logic); the fresh-reset paths are left intact as the
fallback, so the blast radius of a preserve-path bug is "history resets like before," not
corruption.
30 changes: 15 additions & 15 deletions docs/shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,21 @@ The search box is the heart of the app (nvALT-style **search or create**):

## Navigation

| Keys | Action |
| ------------------------ | ------------------------------------------------------------------------------------- |
| `↑` / `↓` (or `k` / `j`) | Preview the previous / next note |
| `⌘J` / `⌘K` | Preview next / previous note (works while editing) |
| `⌘[` / `⌘]` | Go back / forward through visited notes (browser-style history) |
| `Enter` | Edit the selected note (in the title → jump to the body) |
| `⌘Enter` / `⌘-click` | Open the selected note in its own window _(desktop)_ |
| `⌘0` | Show this workspace's main window _(desktop; also Window ▸ Main Window)_ |
| `Esc` | Editor → list → search (then close / clear) |
| `Esc` `Esc` | Focus the search box |
| `⌘L` | Jump to the search box (desktop app; browsers reserve it for the address bar) |
| `⌘\` | Toggle the sidebar |
| `⌘⇧\` | Toggle the folder rail |
| `⌘'` | Peek the collapsed sidebar / focus the list (again to close) |
| `⌃R` | Switch workspace — the recent-folders dialog (`↵` open, `⌘↵` new window, `⌘⌫` remove) |
| Keys | Action |
| ----------------------------------- | ------------------------------------------------------------------------------------- |
| `↑` / `↓` (or `k` / `j`) | Preview the previous / next note |
| `⌘J` / `⌘K` | Preview next / previous note (works while editing) |
| `⌘[` / `⌘]` | Go back / forward through visited notes (browser-style history) |
| `Enter` | Edit the selected note (in the title → jump to the body) |
| `⌘Enter` / `⌘-click` / double-click | Open the selected note in its own window _(desktop)_ |
| `⌘0` | Show this workspace's main window _(desktop; also Window ▸ Main Window)_ |
| `Esc` | Editor → list → search (then close / clear) |
| `Esc` `Esc` | Focus the search box |
| `⌘L` | Jump to the search box (desktop app; browsers reserve it for the address bar) |
| `⌘\` | Toggle the sidebar |
| `⌘⇧\` | Toggle the folder rail |
| `⌘'` | Peek the collapsed sidebar / focus the list (again to close) |
| `⌃R` | Switch workspace — the recent-folders dialog (`↵` open, `⌘↵` new window, `⌘⌫` remove) |

## Editing

Expand Down
20 changes: 16 additions & 4 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,7 @@ fn watch_rel_path(canon_root: &Path, path: &Path) -> Option<String> {
}
match fs::symlink_metadata(path) {
Ok(meta) if meta.is_dir() => Some(segments.join("/")),
Ok(_) => None, // an existing non-md file — not note-relevant
Ok(_) => None, // an existing non-md file — not note-relevant
Err(_) => Some(segments.join("/")), // gone/unreadable — unclassifiable, include
}
}
Expand Down Expand Up @@ -843,7 +843,10 @@ fn remove_window_subscriptions(watchers: &Watchers, label: &str) -> Vec<WatcherE
entry.subscribers.is_empty().then(|| dir.clone())
})
.collect();
emptied.into_iter().filter_map(|dir| map.remove(&dir)).collect()
emptied
.into_iter()
.filter_map(|dir| map.remove(&dir))
.collect()
}

/// Belt for the `notes_watch` ↔ `Destroyed` race: the watcher build runs outside the lock, so a
Expand Down Expand Up @@ -872,7 +875,13 @@ fn handle_watch_events(
// (kFSEventStreamEventFlagMustScanSubDirs — the debouncer strips the flag, so the root
// path is all that's left of it): events were missed, refresh everything. A genuine
// root event (an attr change) is rare enough that over-refreshing on it is cheap.
Ok(events) if events.iter().any(|event| event.path.as_path() == canon_root) => Vec::new(),
Ok(events)
if events
.iter()
.any(|event| event.path.as_path() == canon_root) =>
{
Vec::new()
}
Ok(events) => {
// BTreeSet: dedup (one save fires several events per file) + stable order.
let set: std::collections::BTreeSet<String> = events
Expand Down Expand Up @@ -2254,7 +2263,10 @@ mod tests {
let rel = |p: &Path| watch_rel_path(&root, p);
// Notes pass as POSIX rel-paths — existing or already deleted (a deleted path can't be
// classified, and a missed deletion would be a bug).
assert_eq!(rel(&root.join("Work/Sub/Deep.md")), Some("Work/Sub/Deep.md".into()));
assert_eq!(
rel(&root.join("Work/Sub/Deep.md")),
Some("Work/Sub/Deep.md".into())
);
assert_eq!(rel(&root.join("Note.md")), Some("Note.md".into()));
// A dot-NAMED note passes: the note walks skip dot-DIRS only and do list `.hidden.md`,
// so the watcher must report its changes too (listed-but-never-refreshed otherwise).
Expand Down
Loading
Loading