diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 397b018..0efd8c1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,6 +85,11 @@ jobs: NOTES=$(printf '%s\n' \ 'See assets below to download and install.' \ '' \ + '> [!IMPORTANT]' \ + '> **macOS 11 Big Sur or later is required, with Safari 16.4 or later installed.** DocsReader draws its interface with the system WebView, so the installed Safari decides what it can display: on Big Sur and Monterey, install the latest Safari from Software Update first.' \ + '>' \ + '> On macOS 10.15 Catalina or earlier, DocsReader cannot run. If a Mac on Catalina offers you this update, do not install it - the new version will not open, and no earlier version works on Catalina either. Update macOS first.' \ + '' \ 'Install via Homebrew:' \ '```' \ 'brew install --cask anbturki/tap/docsreader' \ diff --git a/.github/workflows/update-homebrew-tap.yml b/.github/workflows/update-homebrew-tap.yml index 5ebd037..59d6202 100644 --- a/.github/workflows/update-homebrew-tap.yml +++ b/.github/workflows/update-homebrew-tap.yml @@ -78,6 +78,8 @@ jobs: desc "Markdown documentation reader" homepage "https://github.com/anbturki/docsreader" + depends_on macos: ">= :big_sur" + app "DocsReader.app" binary "#{appdir}/DocsReader.app/Contents/MacOS/docsreader-mcp" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..96d7d4c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,177 @@ +# Changelog + +All notable changes to DocsReader are recorded here. + +The format follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/), +and DocsReader adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +Nothing yet. + +## [0.10.0] - 2026-07-23 + +> [!IMPORTANT] +> **Agents: a write with no workspace is now refused instead of falling back to the shared personal workspace.** +> If your agent setup relies on writing without naming a workspace, it will start +> getting an error until a workspace covers the folder it works in. See +> Breaking changes below. + +> [!IMPORTANT] +> **macOS 11 Big Sur or later is now required**, with Safari 16.4 or later installed. +> Earlier builds claimed to support macOS 10.15 Catalina, which they never could. +> The in-app updater cannot check the system version, so a Mac below Big Sur will +> still be offered this update and the installed app will not launch. Update macOS first. + +### Breaking changes + +- **A write with no `workspace` argument is refused unless a workspace covers where the agent is working.** + Previously such a write fell back to the shared personal workspace at `~/notes`, + creating it if it did not exist, so project work was silently absorbed into a + folder nobody chose. Now the write is refused with an error listing the + workspaces that do exist; clients that support prompting are asked to pick one + instead. Affects `write_doc`, `update_doc`, `set_status`, `set_phase`, `archive`, + `rename_doc`, `delete_doc`, `write_task`, `update_task`, `set_task_status` and + `write_memory`. Passing `workspace` explicitly always works, including when it + names the personal workspace: that is a choice rather than a drift. +- **Reads are unchanged.** `list_docs`, `read_doc`, `search_docs`, `list_tasks` and + `search_memory` still fall back to the personal workspace, so a session with + nothing set up can still look around. +- **Only `init_workspace` creates a workspace.** Create-on-first-write is gone. To + restore the old behaviour for a project, run `init_workspace` once against that + project and use the slug it reports. +- **The minimum supported macOS is 11 Big Sur** (previously declared 10.15 Catalina). + The installer now stops on anything older and warns below macOS 13.3, which is + the first release that already carries the required Safari; on Big Sur and + Monterey, update Safari before launching. The Homebrew cask declares the same + requirement. + +### Added + +- **Search inside document contents.** The sidebar search now matches the text + inside documents as well as names, titles and tags. Results are grouped by + document with a match count each, expand to every matching line in context, and + collapse individually. +- **One search for the whole sidebar.** A magnifier in the sidebar header reveals + the search box; the query applies to whichever lens is showing, including Tasks, + where it matches task titles and ids. +- **Scope filters.** Any search can be narrowed to Files, Contents or Tags, from + the sidebar and from quick open. +- **Content matches in quick open.** Quick open lists matches from inside + documents beneath the file-name matches, each with the line that matched, across + every open workspace. File-name ranking still resolves instantly. +- **Find in the open document.** A find bar scoped to the focused pane highlights + every match, steps through them with Enter and Shift+Enter, centres the focused + match and reads a running count. Rendered math is found by its source through + workspace search rather than in the rendered page. +- **Rebindable search shortcuts.** Quick open, find in document and workspace + search are all editable in Settings and take effect without a restart. A cleared + or unusable binding falls back to its default rather than leaving the action + unreachable. +- **Tasks in the main area.** Tasks open as a full-window tab with room for + side-by-side status columns, shown as a board or as one flat list, chosen from + the toolbar while that tab is showing. +- **Collapsible status groups.** Each status folds to its header row, keeping its + count visible, remembered per workspace. A folded status opens itself while a + search or filter has a match inside it, and a card can still be dropped onto it. +- **Task filters in the sidebar header.** Priority and label filters live in a + popover beside search, and the task count moves onto the header row. +- **A sidebar that collapses to its rail.** Collapsing now leaves the lens rail in + place, so every lens stays one click away; the collapse control lives on the rail + and keeps one position in both states. +- **A workspace switcher menu.** The switcher is a single control naming the active + workspace, opening a list of every workspace with add and remove. +- **Four more accent colours** - teal, magenta, bronze and black - and a rebuilt + appearance picker that shows each scheme as a miniature interface and each accent + as a labelled chip. + +### Changed + +- **One toolbar across the window.** The separate document toolbar and sidebar + header are replaced by a single bar spanning the full window width, holding the + workspace switcher. Nothing in it moves as you switch tabs: the search sits at + a fixed point rather than drifting with the length of the path beside it or + with a control appearing for the open tab. +- **Lenses moved to a vertical rail** down the left edge, each an icon with its + name, so adding lenses no longer reflows the sidebar. The sidebar is wider to + pay for it, leaving the content column wider than the whole sidebar used to be. +- **The panels are inset as a set**, the document card trades its shadow for a + hairline border and a smaller corner radius, and the rail carries the chosen + accent as a solid fill that reads the same in light and dark. +- **The sidebar header is a row of controls** - search, filter, refresh and any + count the lens publishes - with no title of its own. The footer file count is + gone; the link back to hidden files stays. +- **Refresh moved from the window toolbar into the sidebar header**, and one + control now both rescans the workspace and reloads whatever the lens is showing. +- **The editor follows the app theme.** Editing surfaces take their colours from + the same theme and accent as the reader, instead of a pasted-in palette that + tracked neither, so greys, the caret and inline code stop changing between + reading and editing. +- **Status colours come from the theme.** Git badges, the external-change banner + and its tab dot, diff rows and the up-to-date line all draw from the theme's + palette rather than their own hard-coded shades. Their hues stay independent of + the accent on purpose, so a green accent cannot turn a removed diff line green. +- **Slate reads as a slate.** It was drawn at full saturation on a blue hue, which + made it a second Blue in the picker. Every other stored accent choice renders + exactly as before. +- **The document explorer is hidden beside a full-window task board**, which is + already a view of the whole workspace. The sidebar toggle still works, and an + ordinary document still opens with the sidebar the way it was left. +- **Agent guidance for choosing a workspace.** The onboarding resource and the tool + descriptions now set out list, reuse, create as the order, ask for a name that + identifies the project rather than "Notes" or "Docs", state that a git repository + is a valid location and that only the notes folder is written, and say that an + already-a-workspace answer means the workspace is ready to use. +- **`init_workspace` reports the slug it assigned.** A slug that another workspace + already holds is refused, naming the folder it collides with; a slug derived from + a folder name gets a suffix instead. Use the returned value on later calls. +- **Workspace listing reflects what is on disk.** Each workspace's slug is read + from its own folder, so editing it takes effect, and workspaces whose folder no + longer exists are dropped from the list instead of being offered. +- **Remembered tabs use a new stored shape.** Upgrading keeps your open tabs. + Downgrading does not: a build older than 0.10.0 reads the new store as empty and + starts with no tabs. + +### Removed + +- **Create-on-first-write.** A write no longer creates the workspace it lands in; + see Breaking changes above. +- The file count in the sidebar footer, and the separate free-text filter on the + task board, which the one sidebar search now covers. + +### Fixed + +- The window's close, minimise and zoom controls were drawn for the wrong + appearance when the chosen theme differed from the system one, leaving them + invisible against the toolbar until hovered. +- Choosing a light or dark theme no longer gets overridden when the system + switches its own appearance, so the app stops turning dark at sunset. +- The whole window could scroll behind the app: a long document grew the page + instead of scrolling inside its pane, producing a second scrollbar and carrying + the tab bar off the top of the window. +- Primary buttons had no hover state anywhere in the app. +- A task card could not be dragged onto another status on macOS: the drag started + and ended but no column ever saw it. +- Dialogs and sheets did not dim the page behind them, so the content behind + competed with the dialog in front. +- Scroll position stopped being remembered in a pane that opened with its document + already loaded, which is what toggling the split does. The same fault dropped the + reader back at a stale position each time an agent rewrote the open file. +- A tab could sit on Loading forever when a read settled at exactly the wrong + moment. +- Opening a file that had been deleted showed a raw filesystem error, absolute path + and error number included, instead of saying the file is gone and pointing at + refreshing the workspace. +- Switching between two already-open tabs re-parsed and re-rendered every open + document, so the switch was visibly slow and the outgoing document lingered. +- A stray vertical scrollbar painted over the tab titles, and in a split view sat + on top of the active tab's name. +- Two workspaces could carry the same slug, and every call naming it went to + whichever registered first, so work landed in the wrong folder and reported + success. An ambiguous slug is now reported rather than guessed. +- Every unnamed workspace showed as "notes" in the switcher, because managed + workspaces all live in a folder of that name. They are now labelled by the + project that contains them. + +[Unreleased]: https://github.com/anbturki/docsreader/compare/v0.10.0...HEAD +[0.10.0]: https://github.com/anbturki/docsreader/compare/v0.9.2...v0.10.0 diff --git a/README.md b/README.md index 5521464..70adef4 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,11 @@ Needs `docsreader-mcp` on your PATH and `jq`. Full setup in the [plugin README]( | --- | --- | | **Rich rendering** | GitHub-flavored Markdown, KaTeX math, Mermaid diagrams, Shiki highlighting across twelve themes | | **Interactive checklists** | Toggle any checkbox from the rendered view; the change writes back to the file | -| **Five lenses** | Tree, Recent, Tags, Pinned, and a Tasks kanban board over one library | +| **Five lenses** | Tree, Recent, Tags, Pinned, and Tasks over one library, on a rail the sidebar collapses to | | **Split view** | Two docs side-by-side or stacked, each with its own tabs and scroll | +| **Full-text search** | Search names, tags, and the text inside documents, from the toolbar's search or the magnifier in the sidebar header; narrow to files, contents, or tags | | **Open with** | Double-click a `.md` in Finder or "Open With DocsReader" to jump straight to a file or folder | -| **Task board** | To Do / In Progress / Done with drag-to-advance and acceptance-criteria progress, consistent with the MCP | +| **Tasks board or list** | Tasks open full-window as a board of To Do / In Progress / Done columns, or as grouped rows; switch from the toolbar, drag a card to change its status, and see acceptance-criteria progress, all consistent with the MCP | | **Agent-aware** | Open docs reload live as agents write; on-disk changes surface a diff; git status shows in the tree | | **Quiet and local** | Minimal chrome, no telemetry, signed updates, notarized on macOS | @@ -84,9 +85,12 @@ More views in [docs/FEATURES.md](docs/FEATURES.md). Manual downloads: `DocsReader_*_universal.dmg` (macOS Intel + Apple Silicon), `DocsReader_*_amd64.AppImage` or `.deb` (Linux), `DocsReader_*_x64-setup.exe` (Windows). +**macOS requirement:** macOS 11 Big Sur or later, with Safari 16.4 or later installed. The app renders in the system WebView, so the installed Safari version decides which CSS features are available. macOS 13.3 Ventura and later ship Safari 16.4 or newer; on Big Sur and Monterey, install the latest Safari from Software Update first. + ## More -- **[What's new](https://github.com/anbturki/docsreader/releases)** - release history and notes +- **[CHANGELOG.md](CHANGELOG.md)** - what changed in each version +- **[What's new](https://github.com/anbturki/docsreader/releases)** - downloads for every release - **[docs/MCP.md](docs/MCP.md)** - connect agents, every tool, the `docsreader://` resources - **[docs/FEATURES.md](docs/FEATURES.md)** - the full feature list - **[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)** - build from source, release process diff --git a/docs/AGENTS-TEMPLATE.md b/docs/AGENTS-TEMPLATE.md index df238b0..6e1c23e 100644 --- a/docs/AGENTS-TEMPLATE.md +++ b/docs/AGENTS-TEMPLATE.md @@ -13,12 +13,20 @@ the `docsreader` MCP server is registered (DocsReader: Settings → AI agents This project keeps its knowledge in a DocsReader workspace at `./notes` (workspace slug: `my-project`), served by the `docsreader` MCP server. Use its tools instead of writing markdown files by hand: they handle slugs, -frontmatter, status folders, and git staging. +frontmatter, status folders, and git staging (files under `./notes` are added +to the index, never committed, and nothing else in the repo is touched). - Pass `workspace: "my-project"` on every docsreader call so nothing lands in the wrong workspace. If a call fails with `workspace_not_found`, create the workspace once with `init_workspace {path: "", - slug: "my-project"}` and retry. + slug: "my-project", name: "My Project"}` and retry. Check + `list_workspaces` first and reuse a match; a reply of "already a DocsReader + workspace" means it exists and is ready, so keep writing there. This repo + being a git repository is not a reason to write elsewhere. The `name` is + what humans pick from in the app, so name it after this project or product + ("Acme Billing API"), never "Notes" or "Docs". +- Never track this project's docs or tasks in `~/notes` or another project's + workspace; a label is not a substitute for a workspace. - Read the `docsreader://onboarding` resource once per session for the full model. - Before writing anything, search: `search_memory {query}` for prior facts, diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c0ba19a..59dec2c 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -15,4 +15,4 @@ cargo build --manifest-path src-tauri/Cargo.toml -p docsreader-mcp ## Releasing -GitHub → Actions → **Cut Release** → enter the version. The workflow bumps `tauri.conf.json` + `Cargo.toml` + `Cargo.lock`, commits, tags, and triggers the release pipeline (signs, notarizes the macOS bundle, drafts a GitHub Release, updates the Homebrew tap). +GitHub → Actions → **Cut Release** → enter the version. The workflow bumps `tauri.conf.json` + both `Cargo.toml`s + `Cargo.lock` + the README download URLs, commits, tags, and triggers the release pipeline (signs, notarizes the macOS bundle, drafts a GitHub Release, updates the Homebrew tap). diff --git a/docs/FEATURES.md b/docs/FEATURES.md index a9c7183..65202a3 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -6,11 +6,11 @@ The full feature list for DocsReader. The [README](../README.md#features) shows - **Rendering:** GitHub-flavored Markdown via remark-gfm (tables, task lists, footnotes, autolinks, strikethrough) - **Interactive checklists:** click any task-list checkbox in a rendered doc to toggle it - the change writes straight back to the markdown file, and a task's acceptance-criteria progress moves with it, no switching to edit mode -- **Math expressions:** LaTeX rendered inline and in blocks via KaTeX +- **Math expressions:** LaTeX rendered inline and in blocks via KaTeX. Formulas are searchable by their LaTeX source (workspace search reads the markdown), not by their rendered glyphs - **Diagrams:** Mermaid renderer (lazy-loaded, follows theme) - **Box-drawing art:** svgbob converts ASCII diagrams to SVG (experimental) - **Code blocks:** 20 bundled language grammars via Shiki, twelve highlighter palettes (5 light, 7 dark) -- **Appearance:** light, dark, or follow-system, with six accent hues +- **Appearance:** light, dark, or follow-system, chosen from a small preview of each; and ten named accents shown as labelled chips - rose, orange, bronze, green, teal, blue, slate, violet, magenta, black. Each carries its own colour rather than a shared saturation, so muted and neutral choices are real: bronze is deliberately soft and black carries no colour at all. The six accents that shipped before are unchanged, so an existing choice looks the same - **Type controls:** font family, body size, and reading column width - **WYSIWYG edit:** a pencil on any open doc opens an in-place editor with a slash menu, block drag handles, a selection toolbar, and live tables - edit the doc as it reads, not raw markdown; agents stay the primary writers. Frontmatter is preserved untouched, an unchanged doc is never rewritten, and a save is refused if an agent changed the file on disk while you were editing @@ -18,16 +18,22 @@ The full feature list for DocsReader. The [README](../README.md#features) shows ## Browsing -- **Workspaces:** keep multiple unrelated folders open and pivot between them +- **Workspaces:** keep multiple unrelated folders open and pivot between them from the switcher at the left of the toolbar, which also adds and removes them - **Open with:** double-click a `.md`/`.markdown`/`.mdx` in Finder, or right-click > Open With DocsReader. A folder opens as a workspace; a file resolves to its workspace (or its parent folder) and opens in the active pane, whether the app was already running or launched by the open -- **Lenses:** five browsing modes over the same library (Tree, Recent, Tags, Pinned, Tasks) +- **Lenses:** five browsing modes over the same library (Tree, Recent, Tags, Pinned, Tasks), picked from a vertical rail that gives each one an icon and its name. Collapsing the sidebar leaves the rail in place, so the lenses stay one click away +- **One header row:** search, filters, the view switch, and a single refresh control share one row at the top of the sidebar, along with any count the lens publishes. Refresh rescans the workspace and reloads whatever the lens is showing, so there is never a second refresh button lower down - **Jump-to-file:** fuzzy finder across every workspace, opens with Cmd+P (binding configurable) - **Document outline:** auto-built TOC that follows the active heading as you scroll -- **Backlinks:** the sidebar lists every doc that links to the one you are reading, grouped by folder +- **Backlinks:** the outline panel lists every doc that links to the one you are reading, grouped by folder - **Tabs:** many docs open at once; scroll position remembered per tab -- **Split view:** read two docs side-by-side or stacked; each pane keeps its own tabs, scroll, and external-change banner. Toggle from the header, drag the splitter to resize, or use Cmd+\ (horizontal), Cmd+Shift+\ (vertical), Cmd+1 / Cmd+2 to focus a pane. "Open in other pane" lives in the file context menu. -- **Search:** filename, path, frontmatter title, or tag -- **Sticky favorites:** pin individual files to the top of any workspace +- **Split view:** read two docs side-by-side or stacked; each pane keeps its own tabs, scroll, and external-change banner. Toggle from the toolbar, drag the splitter to resize, or use Cmd+\ (horizontal), Cmd+Shift+\ (vertical), Cmd+1 / Cmd+2 to focus a pane. "Open in other pane" lives in the file context menu. +- **Search:** three ways in, each rebindable in Settings + - **Jump to a file** (⌘P): every open workspace at once. Ranks file names first, then lists matches found inside documents with the line that matched + - **Search the workspace** (⇧⌘F): a magnifier in the sidebar header reveals the search box and its filters. Results are grouped by document with a match count each, and expand to show every matching line in context. The query applies to whichever lens is showing, including Tasks + - **Find in the open document** (⌘F): highlights every match, with next/previous and a running count +- **Source or page:** workspace search and ⌘P read the markdown, so LaTeX is found there. ⌘F reads the rendered page, so it skips rendered math, whose glyphs are positioned by CSS rather than in reading order +- **Narrow a search:** ⌘P and the workspace search can be limited to Files, Contents, or Tags; on the Tasks lens the query matches task titles and ids instead +- **Sticky favorites:** pin individual files from the tree or any list; each workspace keeps its own set, gathered in the Pinned lens - **Clutter rules:** glob patterns silently exclude files and folders from the explorer ![Split view: two docs side by side, each with its own tab bar](screenshots/split-dark.png) @@ -45,9 +51,12 @@ The full feature list for DocsReader. The [README](../README.md#features) shows ## Tasks - **Task header:** a doc the MCP wrote as a task (Backlog.md-shaped frontmatter) renders a header - status pill, priority, assignee, and an acceptance-criteria progress bar - instead of plain markdown -- **Tasks board:** a kanban lens grouping tasks into To Do / In Progress / Done columns; cards show priority, assignee, and progress, and open the underlying file on click -- **Drag-to-advance:** drag a card to another column to change its status; the write goes through the same core the agents use, so a GUI move and an MCP `set_task_status` stay consistent -- **Board filters:** narrow by free-text title, priority, or label; filters compose +- **Tasks in the sidebar:** a lens stacking tasks under a To Do, In Progress, or Done heading; cards show priority, assignee, and progress, and open the underlying file on click +- **Tasks full-window:** open the tasks as their own tab, where there is room for a board of side-by-side status columns. The document explorer steps aside while it is showing, since the board already lists the workspace +- **Board or list:** a switch in the toolbar, shown only while the tasks tab is open, draws them as that board or as rows grouped under each status, every row carrying its labels, assignee, priority, and progress under a header naming the columns. The choice is remembered across sessions +- **Drag-to-advance:** drag a card to another status to change it, in the sidebar or across the full-window board; the write goes through the same core the agents use, so a move here and an MCP `set_task_status` stay consistent +- **Collapsible groups:** fold a status group away by its heading; the choice is remembered per workspace, and a folded group reopens on its own while a search or filter has matches in it +- **Task filters:** the sidebar search matches task titles and ids, and the filter control beside it holds priority and label in a popover; filters compose, and the header row keeps a running count that reads `shown / total` while anything is narrowing the set. Both the search and the filters apply to whichever view is showing ![The Tasks board beside a task doc with its status pill and acceptance-criteria progress](screenshots/tasks-header.png) diff --git a/docs/MCP.md b/docs/MCP.md index 9e58557..26fe30c 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -26,21 +26,27 @@ Homebrew users from before v0.6.0: run `brew upgrade --cask docsreader` once so ## Tools -Every tool takes an optional `workspace` slug - omit it to use the resolved default (a project `./notes` if present, else `~/notes`). Tool errors carry recovery hints, so agents self-correct instead of stalling. +Every tool takes an optional `workspace` slug. Omit it and the server resolves one from where the agent is working: a project `./notes` if there is one above it, else your `~/notes`. Reads resolve that way always, so a session that has no workspace of its own can still look around. + +Writes do not fall back. A write with no `workspace` argument is refused unless a workspace actually covers where the agent is working: a project `./notes` above it, or `~/notes` when that is where the agent is standing. Working from an unrelated folder is refused even when `~/notes` exists, because filing project work in the shared folder is the mistake this prevents. Clients that can put a question to you are offered a pick from the workspaces that exist; when the client cannot be asked, the refusal lists them and points at `init_workspace`. Only `init_workspace` creates a workspace. An explicit `workspace` slug is always honoured, including `notes`: naming it is a choice, and only an unnamed write can drift. + +Tool errors carry recovery hints, so agents self-correct instead of stalling. + +Give each project its own workspace: labels group work inside a workspace, they do not separate projects. List first, reuse second, create last - `list_workspaces` before writing, and an "already a DocsReader workspace" answer from `init_workspace` means that workspace is ready, not that you should write elsewhere. In order of preference, an agent starting on a project with no workspace should `init_workspace {path: "", name: ""}` (a git repository is fine - only the `notes` folder is written, and files there are staged, never committed); failing that, use a sibling folder such as `/-notes` and pass its slug explicitly; `~/notes` is for work that belongs to no project. The `name` is what the app's switcher lists, so it must identify the project or product (`"Acme Billing API"`, not `"Notes"`). ### Workspaces | Tool | What it does | | --- | --- | | `list_workspaces` | List all known workspaces: registered projects plus the default `~/notes`. Call before choosing where to write. | -| `init_workspace` | Create and register a workspace (`~/notes`, or `/notes` for a project). Fails if the target already has content. | +| `init_workspace` | Create and register a workspace (`~/notes`, or `/notes` for a project). The only tool that creates one: no write will conjure a workspace for you. Fails if `/notes` already holds files, or if an explicit `slug` already belongs to another workspace. An omitted slug is derived from the folder name, suffixed when that name is taken, and returned as `slug`. | | `ping` | Health check; returns `pong`. | ### Docs | Tool | What it does | | --- | --- | -| `write_doc` | Create a doc in the folder matching its status (`research` / `in-progress` / `done` / `archived`), with generated frontmatter. Handles slugs, collisions, and git staging. | +| `write_doc` | Create a doc in the folder matching its status (`research` / `in-progress` / `done` / `archived`), with generated frontmatter. Handles slugs, collisions, and git staging (adds the file, never commits). | | `read_doc` | Read a doc by slug or status-relative path. Concise (frontmatter + snippet) by default, or `detailed` for the full body. | | `list_docs` | List docs newest first; filter by status, phase, or tag (filters AND together). | | `search_docs` | Rank matches across title, tags, slug, and content; returns snippets and `docsreader://` resource URIs. | @@ -77,7 +83,7 @@ Beyond tools, the server exposes read-only MCP resources: ## A typical flow ```jsonc -list_workspaces {} // pick where to write, or omit `workspace` +list_workspaces {} // pick where to write; init_workspace if there is nowhere yet write_doc { "title": "Use Postgres", "status": "done", "body": "..." } write_task { "title": "Add connection pooling", "description": "...", diff --git a/docs/screenshots/context-menu.png b/docs/screenshots/context-menu.png index 7e86d7c..9e46ab3 100644 Binary files a/docs/screenshots/context-menu.png and b/docs/screenshots/context-menu.png differ diff --git a/docs/screenshots/diff.png b/docs/screenshots/diff.png index f65701b..1ec423b 100644 Binary files a/docs/screenshots/diff.png and b/docs/screenshots/diff.png differ diff --git a/docs/screenshots/external-change.png b/docs/screenshots/external-change.png index 55f1990..2ac7d88 100644 Binary files a/docs/screenshots/external-change.png and b/docs/screenshots/external-change.png differ diff --git a/docs/screenshots/horizontal-split.png b/docs/screenshots/horizontal-split.png index 295ed69..2ea0a9d 100644 Binary files a/docs/screenshots/horizontal-split.png and b/docs/screenshots/horizontal-split.png differ diff --git a/docs/screenshots/images.png b/docs/screenshots/images.png index efb6033..64b092e 100644 Binary files a/docs/screenshots/images.png and b/docs/screenshots/images.png differ diff --git a/docs/screenshots/light-theme.png b/docs/screenshots/light-theme.png index 1bdd781..0bce9f2 100644 Binary files a/docs/screenshots/light-theme.png and b/docs/screenshots/light-theme.png differ diff --git a/docs/screenshots/main.png b/docs/screenshots/main.png index b2ca868..19617b1 100644 Binary files a/docs/screenshots/main.png and b/docs/screenshots/main.png differ diff --git a/docs/screenshots/quick-open.png b/docs/screenshots/quick-open.png index 93d2d01..3596144 100644 Binary files a/docs/screenshots/quick-open.png and b/docs/screenshots/quick-open.png differ diff --git a/docs/screenshots/search.png b/docs/screenshots/search.png index 427c598..af7de9e 100644 Binary files a/docs/screenshots/search.png and b/docs/screenshots/search.png differ diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png index acd727e..45fb4b9 100644 Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ diff --git a/docs/screenshots/split-dark.png b/docs/screenshots/split-dark.png index 437e0ce..47bca1e 100644 Binary files a/docs/screenshots/split-dark.png and b/docs/screenshots/split-dark.png differ diff --git a/docs/screenshots/split.png b/docs/screenshots/split.png deleted file mode 100644 index 13c4888..0000000 Binary files a/docs/screenshots/split.png and /dev/null differ diff --git a/docs/screenshots/tasks-board.png b/docs/screenshots/tasks-board.png index 89f41a8..5d3b102 100644 Binary files a/docs/screenshots/tasks-board.png and b/docs/screenshots/tasks-board.png differ diff --git a/docs/screenshots/tasks-drag-after.png b/docs/screenshots/tasks-drag-after.png deleted file mode 100644 index 15129a8..0000000 Binary files a/docs/screenshots/tasks-drag-after.png and /dev/null differ diff --git a/docs/screenshots/tasks-header.png b/docs/screenshots/tasks-header.png index d245724..ad3cd0a 100644 Binary files a/docs/screenshots/tasks-header.png and b/docs/screenshots/tasks-header.png differ diff --git a/docs/screenshots/tree.png b/docs/screenshots/tree.png index f6c5f60..99ab38f 100644 Binary files a/docs/screenshots/tree.png and b/docs/screenshots/tree.png differ diff --git a/install.sh b/install.sh index f5b3c68..82cdc06 100644 --- a/install.sh +++ b/install.sh @@ -19,6 +19,18 @@ info "detecting platform: ${OS}/${ARCH}" case "$OS" in Darwin) + MACOS_VERSION="$(sw_vers -productVersion)" + MACOS_MAJOR="${MACOS_VERSION%%.*}" + MACOS_MINOR="$(printf "%s" "$MACOS_VERSION" | cut -d. -f2)" + MACOS_MINOR="${MACOS_MINOR:-0}" + # 10.15 Catalina was the last shipped 10.x, so 10.16 can only be Big Sur + # reporting itself under the old numbering and must not be turned away. + if [ "$MACOS_MAJOR" = "10" ] && [ "$MACOS_MINOR" -lt 16 ]; then + err "DocsReader needs macOS 11 Big Sur or later, and this Mac is running macOS ${MACOS_VERSION}. Update macOS from System Preferences > Software Update, then run this installer again." + fi + if [ "$MACOS_MAJOR" -lt 13 ] || { [ "$MACOS_MAJOR" = "13" ] && [ "$MACOS_MINOR" -lt 3 ]; }; then + info "macOS ${MACOS_VERSION} does not include Safari 16.4, which DocsReader needs to draw its interface. Install the latest Safari from Software Update before launching the app." + fi case "$ARCH" in arm64|aarch64) PATTERN="aarch64.*\.dmg$" ;; x86_64) PATTERN="x64.*\.dmg$" ;; @@ -32,7 +44,7 @@ case "$OS" in esac ;; *) - err "unsupported OS: $OS — try the GitHub Releases page directly" + err "unsupported OS: $OS - try the GitHub Releases page directly" ;; esac @@ -78,4 +90,4 @@ case "$ASSET" in ;; esac -info "done — open '${APP_NAME}' to launch" +info "done - open '${APP_NAME}' to launch" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 72e7b51..e4e00ec 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -834,6 +834,7 @@ version = "0.6.0" dependencies = [ "chrono", "rayon", + "regex", "schemars 1.2.1", "serde", "serde_json", @@ -3081,9 +3082,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3093,9 +3094,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -3104,9 +3105,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" diff --git a/src-tauri/core/Cargo.toml b/src-tauri/core/Cargo.toml index 08eac84..8729808 100644 --- a/src-tauri/core/Cargo.toml +++ b/src-tauri/core/Cargo.toml @@ -12,6 +12,7 @@ tokio = { version = "1.52.2", features = ["process", "time", "io-util", "macros" serde_json = "1.0.150" chrono = { version = "0.4.45", default-features = false, features = ["clock", "std"] } schemars = { version = "1.2.1", optional = true } +regex = "1.13.1" [dev-dependencies] tokio = { version = "1.52.2", features = ["rt", "rt-multi-thread", "macros"] } diff --git a/src-tauri/core/src/lib.rs b/src-tauri/core/src/lib.rs index 6952e29..10660b9 100644 --- a/src-tauri/core/src/lib.rs +++ b/src-tauri/core/src/lib.rs @@ -8,6 +8,8 @@ pub mod path_guard; pub mod read; pub mod rename; pub mod scan; +mod score; +pub mod search; pub mod slug; pub mod tasks; pub mod update; diff --git a/src-tauri/core/src/read.rs b/src-tauri/core/src/read.rs index 12d29c1..ecd6a9f 100644 --- a/src-tauri/core/src/read.rs +++ b/src-tauri/core/src/read.rs @@ -4,6 +4,7 @@ use serde::Serialize; use crate::error::{CoreError, ErrorCode}; use crate::frontmatter::{parse_doc_meta, split_frontmatter}; +use crate::score::{combine_terms, FieldHits}; use crate::write::DocStatus; /// ~25k tokens at ~4 chars/token; MCP responses stay under this. @@ -180,11 +181,6 @@ pub fn list_docs_core(root: &Path, filters: &DocFilters<'_>) -> Result = tags.iter().map(|t| t.to_lowercase()).collect(); - let mut total = 0u32; - for term in query_lower.split_whitespace() { - let mut term_score = 0u32; - if title_lower.as_deref().is_some_and(|t| t.contains(term)) { - term_score += SCORE_TITLE; - } - if tags_lower.iter().any(|t| t == term) { - term_score += SCORE_TAG; - } - if slug_lower.contains(term) { - term_score += SCORE_SLUG; - } - if body_lower.contains(term) { - term_score += SCORE_CONTENT; - } - if term_score == 0 { - return 0; - } - total += term_score; - } - total + combine_terms(query_lower.split_whitespace().map(|term| FieldHits { + title: title_lower.as_deref().is_some_and(|t| t.contains(term)), + tag: tags_lower.iter().any(|t| t == term), + slug: slug_lower.contains(term), + content: body_lower.contains(term), + })) } fn content_snippet(content: &str, query_lower: &str) -> Option { diff --git a/src-tauri/core/src/scan.rs b/src-tauri/core/src/scan.rs index a25c986..45b8a91 100644 --- a/src-tauri/core/src/scan.rs +++ b/src-tauri/core/src/scan.rs @@ -83,7 +83,14 @@ fn extract_first_heading(content: &str) -> Option { None } -fn parse_meta(content: &str) -> (Option, Vec) { +pub(crate) fn relative_path(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .to_string() +} + +pub(crate) fn parse_meta(content: &str) -> (Option, Vec) { let (fm, _) = split_frontmatter(content); let meta = fm.map(parse_doc_meta).unwrap_or_default(); let title = meta.title.or_else(|| extract_first_heading(content)); @@ -149,20 +156,30 @@ fn read_partial(path: &Path) -> std::io::Result { Ok(String::from_utf8_lossy(&buf).into_owned()) } -pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result { - let root_path = Path::new(&path); - if !root_path.exists() { - return Err(format!("Path does not exist: {}", path)); - } +pub(crate) struct MarkdownWalk { + pub entries: Vec, + pub truncated: bool, + pub skipped: usize, +} - let dirs_visited = Arc::new(AtomicU64::new(0)); - let last_emit = Arc::new(Mutex::new(Instant::now())); +/// The one traversal every library feature goes through, so the file tree and +/// content search can never disagree about which files a folder contains. +pub(crate) fn collect_markdown_entries(root: &Path, on_dir: impl FnMut(&Path)) -> MarkdownWalk { + collect_markdown_entries_until(root, on_dir, || false) +} +/// The walk dominates a search on a large tree, so a superseded query has to be +/// able to leave it early rather than only between files. +pub(crate) fn collect_markdown_entries_until( + root: &Path, + mut on_dir: impl FnMut(&Path), + should_stop: impl Fn() -> bool, +) -> MarkdownWalk { let mut entries: Vec = Vec::new(); let mut truncated = false; - let skipped = AtomicUsize::new(0); + let mut skipped = 0usize; - let walker = WalkDir::new(root_path) + let walker = WalkDir::new(root) .follow_links(true) .into_iter() .filter_entry(|e| { @@ -178,27 +195,20 @@ pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result e, // Permission-denied subtrees and symlink loops arrive as error // entries; count them so the GUI can say files were left out. Err(_) => { - skipped.fetch_add(1, Ordering::Relaxed); + skipped += 1; continue; } }; if entry.file_type().is_dir() { - dirs_visited.fetch_add(1, Ordering::Relaxed); - maybe_emit_walk_progress( - progress, - &path, - entry.path(), - root_path, - &dirs_visited, - 0, - None, - &last_emit, - ); + on_dir(entry.path()); continue; } @@ -206,14 +216,13 @@ pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result MAX_FILE_BYTES { - skipped.fetch_add(1, Ordering::Relaxed); + skipped += 1; continue; } } @@ -225,6 +234,39 @@ pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result Result { + let root_path = Path::new(&path); + if !root_path.exists() { + return Err(format!("Path does not exist: {}", path)); + } + + let dirs_visited = Arc::new(AtomicU64::new(0)); + let last_emit = Arc::new(Mutex::new(Instant::now())); + + let walk = collect_markdown_entries(root_path, |dir| { + dirs_visited.fetch_add(1, Ordering::Relaxed); + maybe_emit_walk_progress( + progress, + &path, + dir, + root_path, + &dirs_visited, + 0, + None, + &last_emit, + ); + }); + let entries = walk.entries; + let truncated = walk.truncated; + let skipped = AtomicUsize::new(walk.skipped); + let total_to_read = entries.len() as u64; let files_processed = Arc::new(AtomicU64::new(0)); @@ -248,12 +290,7 @@ pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result u32 { + let mut total = 0; + if self.title { + total += SCORE_TITLE; + } + if self.tag { + total += SCORE_TAG; + } + if self.slug { + total += SCORE_SLUG; + } + if self.content { + total += SCORE_CONTENT; + } + total + } +} + +/// Every term must hit at least one field (AND); the document score is the sum +/// of the per-term scores. Shared so the in-app search and the MCP search rank +/// the same corpus identically. +pub(crate) fn combine_terms(per_term: impl IntoIterator) -> u32 { + let mut total = 0; + for hits in per_term { + let score = hits.score(); + if score == 0 { + return 0; + } + total += score; + } + total +} + +#[cfg(test)] +mod tests { + use super::*; + + const EVERY_FIELD: FieldHits = FieldHits { + title: true, + tag: true, + slug: true, + content: true, + }; + + #[test] + fn sums_every_hit_field() { + assert_eq!( + EVERY_FIELD.score(), + SCORE_TITLE + SCORE_TAG + SCORE_SLUG + SCORE_CONTENT + ); + } + + #[test] + fn a_term_matching_nothing_zeroes_the_document() { + let score = combine_terms([EVERY_FIELD, FieldHits::default()]); + assert_eq!(score, 0); + } + + #[test] + fn no_terms_scores_zero() { + assert_eq!(combine_terms([]), 0); + } + + #[test] + fn title_outranks_content() { + let title_only = FieldHits { + title: true, + ..FieldHits::default() + }; + let content_only = FieldHits { + content: true, + ..FieldHits::default() + }; + assert!(title_only.score() > content_only.score()); + } +} diff --git a/src-tauri/core/src/search.rs b/src-tauri/core/src/search.rs new file mode 100644 index 0000000..4f41e9d --- /dev/null +++ b/src-tauri/core/src/search.rs @@ -0,0 +1,878 @@ +use std::ops::Range; +use std::path::{Path, PathBuf}; + +use rayon::prelude::*; +use regex::{Regex, RegexBuilder}; +use serde::{Deserialize, Serialize}; + +use crate::error::{CoreError, ErrorCode}; +use crate::frontmatter::split_frontmatter; +use crate::scan::{collect_markdown_entries_until, parse_meta, relative_path}; +use crate::score::{combine_terms, FieldHits}; + +/// How many matching lines are returned per file. A single huge document must +/// not crowd every other result out of the list. +const MAX_LINES_PER_FILE: usize = 5; +/// Context kept before the first match on a line, in characters. +const SNIPPET_LEAD_CHARS: usize = 40; +/// Total snippet width, in characters. +const SNIPPET_WIDTH_CHARS: usize = 240; +/// Ceiling on returned hits. A one-character query against a library at the +/// file cap matches nearly everything, and serialising that costs far more than +/// a reader can use. +const MAX_HITS: usize = 500; + +/// A run of snippet text, already split so the caller never does index +/// arithmetic. Rust byte offsets and JavaScript UTF-16 indices disagree the +/// moment a document contains an emoji or an accent, so offsets must not cross +/// the process boundary. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SnippetSegment { + pub text: String, + pub is_match: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LineMatch { + /// 1-based, counted from the start of the file including any frontmatter. + pub line: u32, + pub segments: Vec, + pub leading_ellipsis: bool, + pub trailing_ellipsis: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ContentHit { + /// The searched folder this hit came from. Quick Open spans several + /// workspaces at once and the same relative path can exist in more than + /// one, so the caller cannot infer it from the paths alone. + pub root: String, + pub path: String, + pub rel_path: String, + pub score: u32, + pub lines: Vec, + /// Total matching lines in the file, which may exceed `lines.len()`. + pub matched_lines: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ContentSearchResult { + pub hits: Vec, + /// True when a newer query superseded this one, so the hits are partial. + pub aborted: bool, + pub truncated: bool, + /// Folders that could not be searched at all. An empty hit list means + /// nothing matched only when this is empty too. + pub failed_roots: Vec, +} + +impl ContentSearchResult { + pub fn empty() -> Self { + Self { + hits: Vec::new(), + aborted: false, + truncated: false, + failed_roots: Vec::new(), + } + } +} + +/// Lets the caller stop an in-flight search. Tauri commands cannot be +/// cancelled, so a superseded query has to bail out cooperatively. +pub trait SearchAbort: Sync { + fn is_aborted(&self) -> bool; +} + +pub struct NeverAborts; + +impl SearchAbort for NeverAborts { + fn is_aborted(&self) -> bool { + false + } +} + +/// Which fields a query is allowed to match. Mirrored in TypeScript as +/// SEARCH_SCOPES; keep the two in step. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SearchScope { + #[default] + All, + Names, + Content, + Tags, +} + +impl SearchScope { + fn matches_name(self) -> bool { + matches!(self, Self::All | Self::Names) + } + + fn matches_content(self) -> bool { + matches!(self, Self::All | Self::Content) + } + + fn matches_tag(self) -> bool { + matches!(self, Self::All | Self::Tags) + } +} + +pub struct ContentQuery { + terms: Vec, + scope: SearchScope, +} + +impl ContentQuery { + /// Returns `None` for a query with no searchable terms. + pub fn parse(query: &str, case_sensitive: bool, scope: SearchScope) -> Option { + let terms: Vec = query + .split_whitespace() + .filter_map(|term| build_term(term, case_sensitive)) + .collect(); + if terms.is_empty() { + return None; + } + Some(Self { terms, scope }) + } +} + +// The pattern is escaped, so the input is matched literally and a stray "(" in +// the search box cannot become a regex. Case folding is left to the regex +// engine because lowercasing the haystack is not length-preserving, which would +// misalign every offset used to build snippets. +fn build_term(term: &str, case_sensitive: bool) -> Option { + RegexBuilder::new(®ex::escape(term)) + .case_insensitive(!case_sensitive) + .build() + .ok() +} + +pub fn search_content( + root: &Path, + query: &ContentQuery, + abort: &dyn SearchAbort, +) -> Result { + if !root.is_dir() { + return Err(CoreError::new( + ErrorCode::WorkspaceNotFound, + format!("folder {} is missing", root.display()), + ) + .with_recovery("reopen the folder to rescan it")); + } + + let walk = collect_markdown_entries_until(root, |_| {}, || abort.is_aborted()); + let mut hits: Vec = walk + .entries + .par_iter() + .filter_map(|entry| { + if abort.is_aborted() { + return None; + } + search_file(root, entry.path(), query) + }) + .collect(); + + sort_hits(&mut hits); + let truncated = cap_hits(&mut hits) || walk.truncated; + + Ok(ContentSearchResult { + hits, + aborted: abort.is_aborted(), + truncated, + failed_roots: Vec::new(), + }) +} + +/// Searches several folders as one request. Quick Open spans every open +/// workspace, and running one request per folder would make them cancel each +/// other, since a newer request marks every older one stale. +pub fn search_roots( + roots: &[PathBuf], + query: &ContentQuery, + abort: &dyn SearchAbort, +) -> ContentSearchResult { + let mut hits = Vec::new(); + let mut truncated = false; + let mut failed_roots = Vec::new(); + for root in roots { + if abort.is_aborted() { + break; + } + match search_content(root, query, abort) { + Ok(result) => { + truncated = truncated || result.truncated; + hits.extend(result.hits); + } + // One unreadable folder must not sink a search across the others, + // so it is reported alongside whatever the rest matched. + Err(_) => failed_roots.push(root.to_string_lossy().into_owned()), + } + } + sort_hits(&mut hits); + let truncated = cap_hits(&mut hits) || truncated; + ContentSearchResult { + hits, + aborted: abort.is_aborted(), + truncated, + failed_roots, + } +} + +fn sort_hits(hits: &mut [ContentHit]) { + hits.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| a.rel_path.cmp(&b.rel_path)) + }); +} + +/// Drops everything past the cap; the caller must already have sorted, so what +/// survives is the best of the set rather than an arbitrary slice. +fn cap_hits(hits: &mut Vec) -> bool { + if hits.len() <= MAX_HITS { + return false; + } + hits.truncate(MAX_HITS); + true +} + +struct DocFields<'a> { + title: Option<&'a str>, + tags: &'a [String], + /// File stem plus the workspace-relative path, so a query can match a + /// folder name the way it matches a file name. + name: &'a str, + body: &'a str, +} + +fn search_file(root: &Path, path: &Path, query: &ContentQuery) -> Option { + // A file deleted or made unreadable between the walk and the read is simply + // not a result; the next scan reconciles the tree. + let content = std::fs::read_to_string(path).ok()?; + let (_, body) = split_frontmatter(&content); + let (title, tags) = parse_meta(&content); + let rel_path = relative_path(root, path); + + let fields = DocFields { + title: title.as_deref(), + tags: &tags, + name: &rel_path, + body, + }; + let score = combine_terms( + query + .terms + .iter() + .map(|term| field_hits(term, &fields, query.scope)), + ); + if score == 0 { + return None; + } + + let (lines, matched_lines) = if query.scope.matches_content() { + matching_lines(body, first_body_line(&content, body), &query.terms) + } else { + (Vec::new(), 0) + }; + + Some(ContentHit { + root: root.to_string_lossy().to_string(), + path: path.to_string_lossy().to_string(), + rel_path, + score, + lines, + matched_lines, + }) +} + +fn field_hits(term: &Regex, fields: &DocFields<'_>, scope: SearchScope) -> FieldHits { + FieldHits { + title: scope.matches_name() && fields.title.is_some_and(|t| term.is_match(t)), + tag: scope.matches_tag() && fields.tags.iter().any(|tag| term.is_match(tag)), + slug: scope.matches_name() && term.is_match(fields.name), + content: scope.matches_content() && term.is_match(fields.body), + } +} + +/// 1-based file line on which the body starts, so reported line numbers point +/// at the real file rather than at the post-frontmatter offset. `body` is a +/// subslice of `content`, so the length gap is exactly the frontmatter block. +fn first_body_line(content: &str, body: &str) -> u32 { + let consumed = content.len() - body.len(); + content[..consumed].matches('\n').count() as u32 + 1 +} + +fn matching_lines(body: &str, first_line: u32, terms: &[Regex]) -> (Vec, u32) { + let mut lines = Vec::new(); + let mut matched = 0u32; + for (offset, line) in body.lines().enumerate() { + let ranges = merge_overlapping(term_ranges(line, terms)); + if ranges.is_empty() { + continue; + } + matched += 1; + if lines.len() < MAX_LINES_PER_FILE { + lines.push(build_line_match(first_line + offset as u32, line, &ranges)); + } + } + (lines, matched) +} + +fn term_ranges(line: &str, terms: &[Regex]) -> Vec> { + let mut ranges: Vec> = terms + .iter() + .flat_map(|term| term.find_iter(line).map(|m| m.range())) + .collect(); + ranges.sort_by_key(|r| (r.start, r.end)); + ranges +} + +fn merge_overlapping(ranges: Vec>) -> Vec> { + let mut merged: Vec> = Vec::with_capacity(ranges.len()); + for range in ranges { + match merged.last_mut() { + Some(last) if range.start <= last.end => last.end = last.end.max(range.end), + _ => merged.push(range), + } + } + merged +} + +fn build_line_match(line: u32, text: &str, ranges: &[Range]) -> LineMatch { + let first_match = ranges.first().map(|r| r.start).unwrap_or(0); + let window = snippet_window(text, first_match); + LineMatch { + line, + segments: build_segments(text, &window, ranges), + leading_ellipsis: window.start > 0, + trailing_ellipsis: window.end < text.len(), + } +} + +fn snippet_window(text: &str, first_match: usize) -> Range { + let start = back_off_chars(text, first_match, SNIPPET_LEAD_CHARS); + let end = forward_chars(text, start, SNIPPET_WIDTH_CHARS); + start..end +} + +fn back_off_chars(text: &str, from: usize, chars: usize) -> usize { + text[..from] + .char_indices() + .rev() + .take(chars) + .last() + .map(|(i, _)| i) + .unwrap_or(from) +} + +fn forward_chars(text: &str, from: usize, chars: usize) -> usize { + text[from..] + .char_indices() + .nth(chars) + .map(|(i, _)| from + i) + .unwrap_or(text.len()) +} + +fn build_segments( + text: &str, + window: &Range, + ranges: &[Range], +) -> Vec { + let mut segments = Vec::new(); + let mut cursor = window.start; + for range in ranges { + if range.start < cursor || range.end > window.end { + continue; + } + push_segment(&mut segments, &text[cursor..range.start], false); + push_segment(&mut segments, &text[range.start..range.end], true); + cursor = range.end; + } + push_segment(&mut segments, &text[cursor..window.end], false); + segments +} + +fn push_segment(segments: &mut Vec, text: &str, is_match: bool) { + if text.is_empty() { + return; + } + segments.push(SnippetSegment { + text: text.to_string(), + is_match, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::workspace::test_dir; + + struct AlwaysAborts; + + impl SearchAbort for AlwaysAborts { + fn is_aborted(&self) -> bool { + true + } + } + + fn write(root: &Path, rel: &str, content: &str) { + let path = root.join(rel); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); + } + + fn search(root: &Path, query: &str) -> Vec { + search_scoped(root, query, SearchScope::All) + } + + fn search_scoped(root: &Path, query: &str, scope: SearchScope) -> Vec { + let parsed = ContentQuery::parse(query, false, scope).expect("query has terms"); + search_content(root, &parsed, &NeverAborts).unwrap().hits + } + + fn matched_text(hit: &ContentHit) -> Vec { + hit.lines + .iter() + .flat_map(|l| l.segments.iter()) + .filter(|s| s.is_match) + .map(|s| s.text.clone()) + .collect() + } + + #[test] + fn finds_a_term_only_present_in_the_body() { + let dir = test_dir("search_body"); + write( + &dir, + "notes.md", + "# Unrelated Title\n\nthe coturn relay flag\n", + ); + + let hits = search(&dir, "coturn"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].rel_path, "notes.md"); + assert_eq!(matched_text(&hits[0]), ["coturn"]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn finds_a_term_beyond_the_partial_read_window() { + let dir = test_dir("search_deep"); + let filler = "lorem ipsum dolor sit amet\n".repeat(2000); + write( + &dir, + "long.md", + &format!("# Long\n\n{filler}\nneedle here\n"), + ); + + let hits = search(&dir, "needle"); + assert_eq!( + hits.len(), + 1, + "content past the 16 KiB scan window is searched" + ); + assert!( + filler.len() > 16 * 1024, + "fixture exceeds the scan read window" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn every_term_must_match_somewhere() { + let dir = test_dir("search_and"); + write(&dir, "both.md", "alpha and beta\n"); + write(&dir, "one.md", "alpha only\n"); + + let hits = search(&dir, "alpha beta"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].rel_path, "both.md"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn title_match_outranks_body_match() { + let dir = test_dir("search_rank"); + write( + &dir, + "body.md", + "# Something Else\n\nmentions gateway once\n", + ); + write( + &dir, + "titled.md", + "---\ntitle: Gateway\n---\n\nunrelated prose\n", + ); + + let hits = search(&dir, "gateway"); + assert_eq!(hits.len(), 2); + assert_eq!(hits[0].rel_path, "titled.md"); + assert!(hits[0].score > hits[1].score); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn reports_file_line_numbers_past_frontmatter() { + let dir = test_dir("search_lines"); + write( + &dir, + "fm.md", + "---\ntitle: X\ntags: [a]\n---\n\nfirst\ntarget line\n", + ); + + let hits = search(&dir, "target"); + assert_eq!(hits[0].lines[0].line, 7); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn frontmatter_only_match_does_not_count_as_content() { + let dir = test_dir("search_fm_only"); + write(&dir, "tagged.md", "---\ntags: [infra]\n---\n\nbody text\n"); + + let hits = search(&dir, "infra"); + assert_eq!(hits.len(), 1, "the tag still matches"); + assert!(hits[0].lines.is_empty(), "but no body line is reported"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn query_metacharacters_are_matched_literally() { + let dir = test_dir("search_meta"); + write(&dir, "regex.md", "a.b literal\n"); + write(&dir, "other.md", "axb should not match\n"); + + let hits = search(&dir, "a.b"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].rel_path, "regex.md"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn case_insensitive_by_default_and_case_sensitive_on_request() { + let dir = test_dir("search_case"); + write(&dir, "case.md", "Gateway rules\n"); + + assert_eq!(search(&dir, "gateway").len(), 1); + + let sensitive = ContentQuery::parse("gateway", true, SearchScope::All).unwrap(); + let hits = search_content(&dir, &sensitive, &NeverAborts).unwrap().hits; + assert!(hits.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn segments_survive_multibyte_content() { + let dir = test_dir("search_utf8"); + write(&dir, "emoji.md", "café ☕ needle 😀 tail\n"); + + let hits = search(&dir, "needle"); + let joined: String = hits[0].lines[0] + .segments + .iter() + .map(|s| s.text.as_str()) + .collect(); + assert!(joined.contains("café ☕")); + assert!(joined.contains('😀')); + assert_eq!(matched_text(&hits[0]), ["needle"]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn overlapping_term_matches_merge_into_one_segment() { + let dir = test_dir("search_overlap"); + write(&dir, "overlap.md", "foobar\n"); + + let parsed = ContentQuery::parse("foo oob", false, SearchScope::All).unwrap(); + let hits = search_content(&dir, &parsed, &NeverAborts).unwrap().hits; + assert_eq!(matched_text(&hits[0]), ["foob"]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn caps_reported_lines_but_counts_them_all() { + let dir = test_dir("search_cap"); + write(&dir, "many.md", &"needle\n".repeat(MAX_LINES_PER_FILE + 4)); + + let hits = search(&dir, "needle"); + assert_eq!(hits[0].lines.len(), MAX_LINES_PER_FILE); + assert_eq!(hits[0].matched_lines as usize, MAX_LINES_PER_FILE + 4); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn long_lines_are_windowed_with_ellipses() { + let dir = test_dir("search_window"); + let pad = "x".repeat(400); + write(&dir, "long_line.md", &format!("{pad} needle {pad}\n")); + + let hits = search(&dir, "needle"); + let line = &hits[0].lines[0]; + assert!(line.leading_ellipsis); + assert!(line.trailing_ellipsis); + let width: usize = line.segments.iter().map(|s| s.text.chars().count()).sum(); + assert!(width <= SNIPPET_WIDTH_CHARS, "snippet stays bounded"); + let _ = std::fs::remove_dir_all(&dir); + } + + fn seed_scoped(tag: &str) -> std::path::PathBuf { + let dir = test_dir(tag); + write( + &dir, + "gateway-notes.md", + "---\ntags: [infra]\n---\n\nplain prose\n", + ); + write( + &dir, + "other.md", + "---\ntitle: Unrelated\n---\n\nthe gateway is here\n", + ); + write( + &dir, + "tagged.md", + "---\ntags: [gateway]\n---\n\nplain prose\n", + ); + dir + } + + #[test] + fn names_scope_matches_the_file_name_only() { + let dir = seed_scoped("scope_names"); + + let hits = search_scoped(&dir, "gateway", SearchScope::Names); + + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].rel_path, "gateway-notes.md"); + assert!( + hits[0].lines.is_empty(), + "no body snippet outside the content scope" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn content_scope_matches_the_body_only() { + let dir = seed_scoped("scope_content"); + + let hits = search_scoped(&dir, "gateway", SearchScope::Content); + + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].rel_path, "other.md"); + assert_eq!(hits[0].lines.len(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn tags_scope_matches_the_tags_only() { + let dir = seed_scoped("scope_tags"); + + let hits = search_scoped(&dir, "gateway", SearchScope::Tags); + + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].rel_path, "tagged.md"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn all_scope_matches_every_field() { + let dir = seed_scoped("scope_all"); + + let hits = search_scoped(&dir, "gateway", SearchScope::All); + + assert_eq!(hits.len(), 3); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn names_scope_matches_a_folder_in_the_path() { + let dir = test_dir("scope_folder"); + write(&dir, "gateway/inner.md", "unrelated prose\n"); + + let hits = search_scoped(&dir, "gateway", SearchScope::Names); + + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].rel_path, "gateway/inner.md"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn scope_defaults_to_searching_everything() { + assert_eq!(SearchScope::default(), SearchScope::All); + } + + #[test] + fn searches_several_folders_in_one_request() { + let one = test_dir("roots_one"); + let two = test_dir("roots_two"); + write(&one, "a.md", "the needle is here\n"); + write(&two, "b.md", "another needle\n"); + + let parsed = ContentQuery::parse("needle", false, SearchScope::All).unwrap(); + let result = search_roots(&[one.clone(), two.clone()], &parsed, &NeverAborts); + + assert_eq!(result.hits.len(), 2); + let roots: Vec<&str> = result.hits.iter().map(|h| h.root.as_str()).collect(); + assert!(roots.contains(&one.to_string_lossy().as_ref())); + assert!(roots.contains(&two.to_string_lossy().as_ref())); + let _ = std::fs::remove_dir_all(&one); + let _ = std::fs::remove_dir_all(&two); + } + + #[test] + fn a_missing_folder_does_not_sink_the_others() { + let good = test_dir("roots_good"); + let missing = test_dir("roots_missing"); + write(&good, "a.md", "needle\n"); + let _ = std::fs::remove_dir_all(&missing); + + let parsed = ContentQuery::parse("needle", false, SearchScope::All).unwrap(); + let result = search_roots(&[missing.clone(), good.clone()], &parsed, &NeverAborts); + + assert_eq!(result.hits.len(), 1); + assert_eq!(result.failed_roots, [missing.to_string_lossy().to_string()]); + let _ = std::fs::remove_dir_all(&good); + } + + #[test] + fn an_aborted_search_stops_before_walking_the_tree() { + let dir = test_dir("walk_abort"); + write(&dir, "a.md", "needle\n"); + + let stopped = crate::scan::collect_markdown_entries_until(&dir, |_| {}, || true); + let full = crate::scan::collect_markdown_entries_until(&dir, |_| {}, || false); + + assert!(stopped.entries.is_empty()); + assert_eq!(full.entries.len(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_aborted_search_does_not_probe_the_next_folder() { + let good = test_dir("roots_abort_good"); + let missing = test_dir("roots_abort_missing"); + write(&good, "a.md", "needle\n"); + let _ = std::fs::remove_dir_all(&missing); + + let parsed = ContentQuery::parse("needle", false, SearchScope::All).unwrap(); + let result = search_roots(&[good.clone(), missing], &parsed, &AlwaysAborts); + + assert!( + result.failed_roots.is_empty(), + "an abandoned search stops instead of reading the remaining folders" + ); + let _ = std::fs::remove_dir_all(&good); + } + + #[test] + fn caps_the_hit_set_and_reports_it_truncated() { + let dir = test_dir("search_hit_cap"); + for i in 0..MAX_HITS + 5 { + write(&dir, &format!("f{i}.md"), "needle\n"); + } + + let parsed = ContentQuery::parse("needle", false, SearchScope::All).unwrap(); + let result = search_roots(std::slice::from_ref(&dir), &parsed, &NeverAborts); + + assert_eq!(result.hits.len(), MAX_HITS); + assert!(result.truncated); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_searchable_folder_is_not_reported_as_failed() { + let dir = test_dir("roots_ok"); + write(&dir, "a.md", "needle\n"); + + let parsed = ContentQuery::parse("needle", false, SearchScope::All).unwrap(); + let result = search_roots(std::slice::from_ref(&dir), &parsed, &NeverAborts); + + assert!(result.failed_roots.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn every_hit_reports_the_folder_it_came_from() { + let dir = test_dir("roots_reported"); + write(&dir, "a.md", "needle\n"); + + let hits = search_scoped(&dir, "needle", SearchScope::All); + + assert_eq!(hits[0].root, dir.to_string_lossy()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn no_matches_returns_no_hits() { + let dir = test_dir("search_none"); + write(&dir, "a.md", "nothing relevant\n"); + + assert!(search(&dir, "absent").is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn blank_query_has_no_terms() { + assert!(ContentQuery::parse(" ", false, SearchScope::All).is_none()); + assert!(ContentQuery::parse("", false, SearchScope::All).is_none()); + } + + #[test] + fn abort_stops_the_search_and_is_reported() { + let dir = test_dir("search_abort"); + write(&dir, "a.md", "needle\n"); + + let parsed = ContentQuery::parse("needle", false, SearchScope::All).unwrap(); + let result = search_content(&dir, &parsed, &AlwaysAborts).unwrap(); + assert!(result.aborted); + assert!(result.hits.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn missing_folder_is_an_error() { + let dir = test_dir("search_missing"); + let _ = std::fs::remove_dir_all(&dir); + let parsed = ContentQuery::parse("x", false, SearchScope::All).unwrap(); + assert!(search_content(&dir, &parsed, &NeverAborts).is_err()); + } + + #[test] + fn skips_non_utf8_files_without_failing_the_search() { + let dir = test_dir("search_binary"); + write(&dir, "good.md", "needle\n"); + std::fs::write(dir.join("bad.md"), [0xff, 0xfe, 0x00, 0x6e]).unwrap(); + + let hits = search(&dir, "needle"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].rel_path, "good.md"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn query_longer_than_the_file_matches_nothing() { + let dir = test_dir("search_long_query"); + write(&dir, "tiny.md", "hi\n"); + + assert!(search(&dir, "a query far longer than the document").is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn honours_the_scan_skip_rules() { + let dir = test_dir("search_skips"); + write(&dir, "visible.md", "needle\n"); + write(&dir, "node_modules/hidden.md", "needle\n"); + write(&dir, ".hidden/secret.md", "needle\n"); + + let hits = search(&dir, "needle"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].rel_path, "visible.md"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/core/src/workspace/init.rs b/src-tauri/core/src/workspace/init.rs index 8c7ebb0..6bebbdc 100644 --- a/src-tauri/core/src/workspace/init.rs +++ b/src-tauri/core/src/workspace/init.rs @@ -2,7 +2,9 @@ use std::path::{Path, PathBuf}; use serde::Serialize; -use super::registry::{load_registry, upsert_workspace, WorkspaceEntry}; +use super::registry::{ + live_workspaces, load_registry, same_folder, upsert_workspace, WorkspaceEntry, +}; use super::{load_marker, save_marker, WorkspaceMarker, WorkspaceScope, MARKER_FILE}; use crate::error::{CoreError, ErrorCode}; use crate::slug::slugify; @@ -38,7 +40,9 @@ fn ensure_target_is_fresh(root: &Path) -> Result<(), CoreError> { ErrorCode::Conflict, format!("{} is already a DocsReader workspace", root.display()), ) - .with_recovery("it is ready to use; call write_doc or list_workspaces")); + .with_recovery( + "nothing to fix: this is the workspace to use. Take its slug from list_workspaces, pass it on later calls, and carry on writing here instead of looking for another location", + )); } let has_content = std::fs::read_dir(root)?.next().is_some(); if has_content { @@ -49,7 +53,9 @@ fn ensure_target_is_fresh(root: &Path) -> Result<(), CoreError> { root.display() ), ) - .with_recovery("pick an empty or new directory, or convert it in the DocsReader app")); + .with_recovery( + "only pre-existing files block init; where the folder lives does not, and a git repository is a valid home. Point path at a folder whose notes/ is empty or missing, or convert this one in the DocsReader app", + )); } Ok(()) } @@ -95,8 +101,8 @@ pub fn init_workspace_core( ) -> Result { ensure_target_is_fresh(root)?; let slug = match slug { - Some(s) => s.to_string(), - None => default_slug(root, scope), + Some(explicit) => reject_taken_slug(explicit, root, registry_file)?, + None => free_slug(default_slug(root, scope), root, registry_file)?, }; materialize_workspace(root, slug, name, scope, registry_file) } @@ -131,9 +137,23 @@ pub fn convert_workspace_core( materialize_workspace(root, slug, None, WorkspaceScope::Project, registry_file) } +/// The workspace holding a slug, if another one does. `live_workspaces` has +/// already dropped folders that are gone and taken each slug from the folder +/// itself, so neither a deleted workspace nor a stale entry blocks the name. +fn slug_owner(slug: &str, root: &Path, entries: &[WorkspaceEntry]) -> Option { + entries + .iter() + .find(|e| e.slug == slug && !same_folder(&e.path, root)) + .map(|e| e.path.clone()) +} + +fn registered_workspaces(registry_file: &Path) -> Result, CoreError> { + Ok(live_workspaces(load_registry(registry_file)?)) +} + fn free_slug(base: String, root: &Path, registry_file: &Path) -> Result { - let entries = load_registry(registry_file)?; - let taken = |slug: &str| entries.iter().any(|e| e.slug == slug && e.path != root); + let entries = registered_workspaces(registry_file)?; + let taken = |slug: &str| slug_owner(slug, root, &entries).is_some(); if !taken(&base) { return Ok(base); } @@ -143,6 +163,26 @@ fn free_slug(base: String, root: &Path, registry_file: &Path) -> Result Result { + let entries = registered_workspaces(registry_file)?; + match slug_owner(slug, root, &entries) { + Some(owner) => Err(CoreError::new( + ErrorCode::Conflict, + format!( + "the slug {slug:?} already belongs to the workspace at {}", + owner.display() + ), + ) + .with_recovery( + "if that workspace is the one this project needs, keep using that slug and write there. Otherwise retry with a slug no workspace uses yet, or omit slug to have one derived from the folder name and reported back", + )), + None => Ok(slug.to_string()), + } +} + #[cfg(test)] mod tests { use super::super::test_dir; @@ -223,6 +263,96 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn explicit_slug_taken_by_another_workspace_is_refused() { + let dir = test_dir("init_dupe_explicit"); + let registry = dir.join("registry.json"); + let first = dir.join("a/notes"); + let second = dir.join("b/notes"); + + init_workspace_core( + &first, + Some("acme"), + None, + WorkspaceScope::Project, + ®istry, + ) + .unwrap(); + let err = init_workspace_core( + &second, + Some("acme"), + None, + WorkspaceScope::Project, + ®istry, + ) + .unwrap_err(); + + assert_eq!(err.code, ErrorCode::Conflict); + assert!( + err.message.contains(&first.display().to_string()), + "message names the workspace it collides with: {}", + err.message + ); + assert!(!second.exists(), "the refused workspace is not created"); + assert_eq!( + super::super::registry::load_registry(®istry) + .unwrap() + .len(), + 1 + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn default_slug_collision_across_parents_is_suffixed() { + let dir = test_dir("init_dupe_default"); + let registry = dir.join("registry.json"); + let first = dir.join("one/proj/notes"); + let second = dir.join("two/proj/notes"); + + assert_eq!( + init_workspace_core(&first, None, None, WorkspaceScope::Project, ®istry) + .unwrap() + .slug, + "proj" + ); + assert_eq!( + init_workspace_core(&second, None, None, WorkspaceScope::Project, ®istry) + .unwrap() + .slug, + "proj-2" + ); + assert_eq!( + load_marker(&second).unwrap().unwrap().slug, + "proj-2", + "the marker records the assigned slug" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn every_initialized_workspace_resolves_to_its_own_root() { + use super::super::resolve::resolve_workspace; + + let dir = test_dir("init_resolves_apart"); + let registry_file = dir.join("registry.json"); + let home = dir.join("home"); + let first = dir.join("one/proj/notes"); + let second = dir.join("two/proj/notes"); + init_workspace_core(&first, None, None, WorkspaceScope::Project, ®istry_file).unwrap(); + init_workspace_core(&second, None, None, WorkspaceScope::Project, ®istry_file).unwrap(); + let entries = super::super::registry::load_registry(®istry_file).unwrap(); + + for (slug, expected) in [("proj", &first), ("proj-2", &second)] { + let resolved = resolve_workspace(Some(slug), &[], &dir, &home, &entries) + .unwrap_or_else(|e| { + panic!("{slug} should resolve: {e}"); + }); + assert_eq!(&resolved.root, expected); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn init_on_populated_non_workspace_dir_is_conflict() { let dir = test_dir("init_populated"); diff --git a/src-tauri/core/src/workspace/registry.rs b/src-tauri/core/src/workspace/registry.rs index e48fc4d..68a1e2b 100644 --- a/src-tauri/core/src/workspace/registry.rs +++ b/src-tauri/core/src/workspace/registry.rs @@ -108,21 +108,58 @@ pub fn save_registry(file: &Path, workspaces: &[WorkspaceEntry]) -> Result<(), C write_atomically(file, &raw) } -/// Replaces any entry with the same path, so re-registering updates slug/scope. +/// Replaces any entry naming the same folder, so re-registering updates +/// slug/scope instead of adding a second entry for one workspace. pub fn upsert_workspace(file: &Path, entry: WorkspaceEntry) -> Result<(), CoreError> { // Held across the whole load-modify-save: concurrent sidecars would otherwise // each read the same registry and the last save would drop the other entries. let _lock = RegistryLock::acquire(file)?; let mut workspaces = load_registry(file)?; - workspaces.retain(|w| w.path != entry.path); + workspaces.retain(|w| !same_folder(&w.path, &entry.path)); workspaces.push(entry); save_registry(file, &workspaces) } -/// Drops entries whose directory is gone, so the GUI never surfaces a broken -/// empty root for a workspace deleted out from under the registry. -pub fn existing_workspaces(entries: Vec) -> Vec { - entries.into_iter().filter(|w| w.path.is_dir()).collect() +/// The workspaces the registry can actually serve right now: entries whose +/// folder is still there, each carrying the slug that folder answers to. +/// +/// The stored registry is left as the user has it: reconciliation happens on +/// the way out, never on disk. +pub fn live_workspaces(entries: Vec) -> Vec { + let mut live: Vec = Vec::new(); + for entry in entries.into_iter().filter(|w| w.path.is_dir()) { + if live.iter().any(|kept| same_folder(&kept.path, &entry.path)) { + continue; + } + live.push(with_marker_slug(entry)); + } + live +} + +/// A marker that is missing or unreadable leaves the recorded slug standing: +/// listing workspaces must not fail over one folder's damaged marker. +fn with_marker_slug(entry: WorkspaceEntry) -> WorkspaceEntry { + match super::load_marker(&entry.path).ok().flatten() { + Some(marker) => WorkspaceEntry { + slug: marker.slug, + ..entry + }, + None => entry, + } +} + +/// Whether two paths name one folder: a symlink and its target, or a relative +/// and an absolute spelling, are the same workspace. Resolving a path needs it +/// to exist, so an entry whose folder is gone falls back to the literal +/// comparison rather than becoming an error. +pub fn same_folder(a: &Path, b: &Path) -> bool { + if a == b { + return true; + } + match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } } #[cfg(test)] @@ -165,7 +202,7 @@ mod tests { } #[test] - fn existing_workspaces_drops_missing_dirs() { + fn live_workspaces_drops_missing_dirs() { let dir = test_dir("reg_existing"); let present = dir.join("present"); std::fs::create_dir_all(&present).unwrap(); @@ -177,12 +214,149 @@ mod tests { }, entry("gone", "/no/such/workspace/dir", WorkspaceScope::Project), ]; - let kept = existing_workspaces(entries); + let kept = live_workspaces(entries); assert_eq!(kept.len(), 1); assert_eq!(kept[0].path, present); let _ = std::fs::remove_dir_all(&dir); } + #[cfg(unix)] + #[test] + fn live_workspaces_list_a_folder_registered_twice_once() { + let dir = test_dir("reg_double_listed"); + let real = dir.join("real/notes"); + std::fs::create_dir_all(&real).unwrap(); + let link = dir.join("link"); + std::os::unix::fs::symlink(dir.join("real"), &link).unwrap(); + + let live = live_workspaces(vec![ + entry("acme", real.to_str().unwrap(), WorkspaceScope::Project), + entry( + "acme", + link.join("notes").to_str().unwrap(), + WorkspaceScope::Project, + ), + ]); + assert_eq!(live.len(), 1, "one folder, one listing: {live:?}"); + assert_eq!(live[0].path, real); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn one_folder_spelled_two_ways_stays_one_entry() { + let dir = test_dir("reg_same_folder"); + let file = dir.join(REGISTRY_FILE); + let real = dir.join("real/notes"); + std::fs::create_dir_all(&real).unwrap(); + let link = dir.join("link"); + std::os::unix::fs::symlink(dir.join("real"), &link).unwrap(); + + upsert_workspace( + &file, + WorkspaceEntry { + slug: "first".into(), + path: PathBuf::from(format!("{}/", real.display())), + scope: WorkspaceScope::Project, + }, + ) + .unwrap(); + upsert_workspace( + &file, + WorkspaceEntry { + slug: "second".into(), + path: real.clone(), + scope: WorkspaceScope::Project, + }, + ) + .unwrap(); + upsert_workspace( + &file, + WorkspaceEntry { + slug: "third".into(), + path: link.join("notes"), + scope: WorkspaceScope::Project, + }, + ) + .unwrap(); + + let entries = load_registry(&file).unwrap(); + assert_eq!(entries.len(), 1, "one folder, one entry: {entries:?}"); + assert_eq!(entries[0].slug, "third"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn live_workspaces_take_their_slug_from_the_folder_marker() { + let dir = test_dir("reg_marker_drift"); + let ws = dir.join("proj/notes"); + super::super::save_marker( + &ws, + &super::super::WorkspaceMarker { + slug: "renamed-by-hand".into(), + name: None, + homepage: None, + }, + ) + .unwrap(); + + let live = live_workspaces(vec![WorkspaceEntry { + slug: "stale".into(), + path: ws.clone(), + scope: WorkspaceScope::Project, + }]); + assert_eq!(live.len(), 1); + assert_eq!(live[0].slug, "renamed-by-hand"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn live_workspaces_keep_the_registry_slug_when_the_folder_has_no_usable_marker() { + let dir = test_dir("reg_marker_absent"); + let bare = dir.join("bare"); + let broken = dir.join("broken"); + std::fs::create_dir_all(&bare).unwrap(); + std::fs::create_dir_all(&broken).unwrap(); + std::fs::write( + broken.join(super::super::MARKER_FILE), + "slug: \"Not A Slug\"", + ) + .unwrap(); + + let live = live_workspaces(vec![ + entry("bare", bare.to_str().unwrap(), WorkspaceScope::Project), + entry("broken", broken.to_str().unwrap(), WorkspaceScope::Project), + ]); + assert_eq!( + live.iter().map(|w| w.slug.as_str()).collect::>(), + ["bare", "broken"] + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_registry_naming_a_missing_folder_still_loads() { + let dir = test_dir("reg_missing_folder"); + let file = dir.join(REGISTRY_FILE); + let present = dir.join("present"); + std::fs::create_dir_all(&present).unwrap(); + save_registry( + &file, + &[ + entry("gone", "/no/such/workspace/dir", WorkspaceScope::Project), + entry("here", present.to_str().unwrap(), WorkspaceScope::User), + ], + ) + .unwrap(); + + let loaded = load_registry(&file).unwrap(); + assert_eq!(loaded.len(), 2, "the stored registry is left intact"); + let live = live_workspaces(loaded); + assert_eq!(live.len(), 1); + assert_eq!(live[0].slug, "here"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn concurrent_upserts_keep_every_entry() { const WRITERS: usize = 8; diff --git a/src-tauri/core/src/workspace/resolve.rs b/src-tauri/core/src/workspace/resolve.rs index a75b97f..06c1a48 100644 --- a/src-tauri/core/src/workspace/resolve.rs +++ b/src-tauri/core/src/workspace/resolve.rs @@ -1,18 +1,30 @@ use std::path::{Path, PathBuf}; -use super::registry::WorkspaceEntry; -use super::{load_marker, WorkspaceScope}; +use super::registry::{live_workspaces, same_folder, WorkspaceEntry}; +use super::{load_marker, WorkspaceScope, MARKER_FILE}; use crate::error::{CoreError, ErrorCode}; pub const DEFAULT_WORKSPACE_DIR: &str = "notes"; pub const DEFAULT_USER_SLUG: &str = "notes"; +/// Whether the answer names the workspace covering the place the caller is +/// working, or the user default handed back because the search found none. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkspaceOrigin { + Found, + Fallback, +} + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct ResolvedWorkspace { pub root: PathBuf, pub slug: String, pub scope: WorkspaceScope, + // Kept out of the wire format: no tool reports it yet, and adding it would + // change every tool result that carries a workspace. + #[serde(skip)] + pub origin: WorkspaceOrigin, } /// Resolution order mirrors Claude Code's user+project hierarchy: @@ -30,7 +42,7 @@ pub fn resolve_workspace( ) -> Result { let ambient = ambient_workspace(roots_hint, cwd, home)?; match explicit_slug { - Some(slug) => resolve_explicit(slug, registry, ambient), + Some(slug) => resolve_explicit(slug, &live_workspaces(registry.to_vec()), ambient), None => Ok(ambient), } } @@ -40,18 +52,32 @@ fn ambient_workspace( cwd: &Path, home: &Path, ) -> Result { - for base in roots_hint.iter().map(PathBuf::as_path).chain(walk_up(cwd)) { + let bases: Vec<&Path> = roots_hint + .iter() + .map(PathBuf::as_path) + .chain(walk_up(cwd)) + .collect(); + for base in &bases { // ~/notes is the user default, not a project workspace; skip the home // directory so the walk-up never tags it Project. user_default below // classifies it as User, keeping scope consistent with the registry. - if base == home { + if same_folder(base, home) { continue; } if let Some(found) = project_workspace_at(base)? { return Ok(found); } } - user_default(home) + user_default(home, working_inside_user_workspace(&bases, home)) +} + +/// The walk-up looks for `base/notes`, so the user workspace never answers it +/// for itself: a caller standing in ~/notes is recognised here instead. That +/// is the one case where the user default is the workspace covering the +/// caller's location rather than the answer left when nothing was found. +fn working_inside_user_workspace(bases: &[&Path], home: &Path) -> bool { + let root = home.join(DEFAULT_WORKSPACE_DIR); + bases.iter().any(|base| same_folder(base, &root)) } fn resolve_explicit( @@ -59,25 +85,101 @@ fn resolve_explicit( registry: &[WorkspaceEntry], ambient: ResolvedWorkspace, ) -> Result { - if let Some(entry) = registry.iter().find(|w| w.slug == slug && w.path.is_dir()) { - return Ok(ResolvedWorkspace { + let mut matches = workspaces_with_slug(slug, registry, &ambient); + match matches.len() { + 0 => Err(unknown_slug(slug, registry, &ambient)), + 1 => Ok(matches.remove(0)), + _ => Err(ambiguous_slug(slug, &matches)), + } +} + +/// Every workspace the slug could mean: registered entries plus the ambient +/// one, which may still be unregistered. Two folders sharing a slug is a +/// real state on disk, so more than one answer is possible. +fn workspaces_with_slug( + slug: &str, + registry: &[WorkspaceEntry], + ambient: &ResolvedWorkspace, +) -> Vec { + let mut found: Vec = Vec::new(); + let mut add = |candidate: ResolvedWorkspace| { + if candidate.slug == slug + && candidate.root.is_dir() + && !found.iter().any(|w| same_folder(&w.root, &candidate.root)) + { + found.push(candidate); + } + }; + for entry in registry { + add(ResolvedWorkspace { root: entry.path.clone(), slug: entry.slug.clone(), scope: entry.scope, + origin: WorkspaceOrigin::Found, }); } - if ambient.slug == slug && ambient.root.is_dir() { - return Ok(ambient); - } - let available = available_slugs(registry, Some(&ambient)); - Err(CoreError::new( + add(ambient.clone()); + found +} + +fn unknown_slug(slug: &str, registry: &[WorkspaceEntry], ambient: &ResolvedWorkspace) -> CoreError { + let available = available_slugs(registry, Some(ambient)); + CoreError::new( ErrorCode::WorkspaceNotFound, format!("no workspace with slug {slug:?}"), ) .with_recovery(format!( "available workspaces: [{}]; call list_workspaces or init_workspace", available.join(", ") - ))) + )) +} + +/// Refusal for an un-slugged write whose resolution was a fallback: nothing at +/// the caller's location names a workspace, so the user default was all that +/// was left. Writing would file the work in a shared folder nobody chose, so +/// the caller is sent to pick an existing workspace or create one. +pub fn no_write_target(available: &[String], fallback_root: &Path) -> CoreError { + let create_personal = format!( + ", or with no arguments to create your personal notes at {}", + fallback_root.display() + ); + let recovery = if available.is_empty() { + format!( + "no workspace exists yet: call init_workspace with the project directory to create one for this project{create_personal}, then retry with the slug it reports" + ) + } else { + let create_personal = if fallback_root.is_dir() { + String::new() + } else { + create_personal + }; + format!( + "retry with workspace set to one of [{}] to use one of those, or call init_workspace with the project directory to create one for this project{create_personal}", + available.join(", ") + ) + }; + CoreError::new( + ErrorCode::WorkspaceNotFound, + "nothing at this location names a workspace to write to, and writing anyway would file this work in a shared folder that identifies no project", + ) + .with_recovery(recovery) +} + +fn ambiguous_slug(slug: &str, matches: &[ResolvedWorkspace]) -> CoreError { + let paths: Vec = matches + .iter() + .map(|w| w.root.display().to_string()) + .collect(); + CoreError::new( + ErrorCode::Conflict, + format!( + "the slug {slug:?} belongs to more than one workspace: {}", + paths.join(", ") + ), + ) + .with_recovery(format!( + "these folders share a slug, so there is no safe way to pick one and writing to the wrong one would be silent. A workspace takes its slug from the {MARKER_FILE} in its own folder, so give one of them a different slug there and retry. Meanwhile use a slug only one workspace has", + )) } /// Slugs that would resolve right now: registered workspaces whose directory @@ -86,14 +188,15 @@ pub fn available_slugs( registry: &[WorkspaceEntry], ambient: Option<&ResolvedWorkspace>, ) -> Vec { - let mut available: Vec = registry + let mut available: Vec = Vec::new(); + let live = live_workspaces(registry.to_vec()); + let candidates = live .iter() - .filter(|w| w.path.is_dir()) .map(|w| w.slug.clone()) - .collect(); - if let Some(ambient) = ambient { - if ambient.root.is_dir() && !available.iter().any(|s| s == &ambient.slug) { - available.push(ambient.slug.clone()); + .chain(ambient.filter(|a| a.root.is_dir()).map(|a| a.slug.clone())); + for slug in candidates { + if !available.contains(&slug) { + available.push(slug); } } available @@ -110,21 +213,24 @@ fn project_workspace_at(base: &Path) -> Result, CoreEr root: candidate, slug: marker.slug, scope: WorkspaceScope::Project, + origin: WorkspaceOrigin::Found, })), None => Ok(None), } } -fn user_default(home: &Path) -> Result { +fn user_default(home: &Path, working_inside: bool) -> Result { let root = home.join(DEFAULT_WORKSPACE_DIR); - let slug = match load_marker(&root)? { - Some(marker) => marker.slug, - None => DEFAULT_USER_SLUG.to_string(), + let marker = load_marker(&root)?; + let origin = match (&marker, working_inside) { + (Some(_), true) => WorkspaceOrigin::Found, + _ => WorkspaceOrigin::Fallback, }; Ok(ResolvedWorkspace { root, - slug, + slug: marker.map_or_else(|| DEFAULT_USER_SLUG.to_string(), |m| m.slug), scope: WorkspaceScope::User, + origin, }) } @@ -207,6 +313,23 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[cfg(unix)] + #[test] + fn another_spelling_of_home_is_still_home_to_the_walk_up() { + let dir = test_dir("res_home_symlink"); + let home = dir.join("home"); + save_marker(&home.join("notes"), &marker("ali-notes")).unwrap(); + let link = dir.join("home-link"); + std::os::unix::fs::symlink(&home, &link).unwrap(); + let cwd = link.join("projects/some-repo"); + std::fs::create_dir_all(&cwd).unwrap(); + + let resolved = resolve_workspace(None, &[], &cwd, &home, &[]).unwrap(); + assert_eq!(resolved.scope, WorkspaceScope::User); + assert_eq!(resolved.origin, WorkspaceOrigin::Fallback); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn user_workspace_marker_slug_wins_over_default() { let dir = test_dir("res_userslug"); @@ -219,6 +342,114 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn project_workspace_is_a_real_resolution() { + let dir = test_dir("res_origin_project"); + let project = dir.join("repo"); + save_marker(&project.join("notes"), &marker("repo-notes")).unwrap(); + + let resolved = resolve_workspace(None, &[], &project, &dir.join("home"), &[]).unwrap(); + assert_eq!(resolved.origin, WorkspaceOrigin::Found); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn working_inside_the_user_workspace_is_a_real_resolution() { + let dir = test_dir("res_origin_inside_user"); + let home = dir.join("home"); + let root = home.join("notes"); + save_marker(&root, &marker("ali-notes")).unwrap(); + let cwd = root.join("areas/health"); + std::fs::create_dir_all(&cwd).unwrap(); + + for cwd in [root.as_path(), cwd.as_path()] { + let resolved = resolve_workspace(None, &[], cwd, &home, &[]).unwrap(); + assert_eq!(resolved.slug, "ali-notes"); + assert_eq!( + resolved.origin, + WorkspaceOrigin::Found, + "a caller standing in the user workspace means it: {}", + cwd.display() + ); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_roots_hint_at_the_user_workspace_is_a_real_resolution() { + let dir = test_dir("res_origin_hint_user"); + let home = dir.join("home"); + let root = home.join("notes"); + save_marker(&root, &marker("ali-notes")).unwrap(); + let cwd = dir.join("elsewhere"); + std::fs::create_dir_all(&cwd).unwrap(); + + let resolved = + resolve_workspace(None, std::slice::from_ref(&root), &cwd, &home, &[]).unwrap(); + assert_eq!(resolved.origin, WorkspaceOrigin::Found); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_existing_user_workspace_is_still_a_fallback_from_an_unrelated_folder() { + let dir = test_dir("res_origin_user"); + let home = dir.join("home"); + save_marker(&home.join("notes"), &marker("ali-notes")).unwrap(); + let elsewhere = dir.join("unrelated/src"); + std::fs::create_dir_all(&elsewhere).unwrap(); + + for cwd in [home.as_path(), elsewhere.as_path()] { + let resolved = resolve_workspace(None, &[], cwd, &home, &[]).unwrap(); + assert_eq!(resolved.slug, "ali-notes"); + assert_eq!( + resolved.origin, + WorkspaceOrigin::Fallback, + "a set-up user workspace is not evidence the caller meant it: {}", + cwd.display() + ); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn standing_in_a_bare_user_notes_folder_is_still_a_fallback() { + let dir = test_dir("res_origin_bare_user"); + let home = dir.join("home"); + let root = home.join("notes"); + std::fs::create_dir_all(&root).unwrap(); + + let resolved = resolve_workspace(None, &[], &root, &home, &[]).unwrap(); + assert_eq!(resolved.slug, DEFAULT_USER_SLUG); + assert_eq!(resolved.origin, WorkspaceOrigin::Fallback); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn nothing_found_anywhere_is_a_fallback() { + let dir = test_dir("res_origin_fallback"); + let home = dir.join("home"); + let cwd = dir.join("elsewhere"); + std::fs::create_dir_all(&cwd).unwrap(); + + let resolved = resolve_workspace(None, &[], &cwd, &home, &[]).unwrap(); + assert_eq!(resolved.root, home.join("notes")); + assert_eq!(resolved.origin, WorkspaceOrigin::Fallback); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn origin_stays_out_of_the_serialized_shape() { + let dir = test_dir("res_origin_serde"); + let home = dir.join("home"); + let resolved = resolve_workspace(None, &[], &dir, &home, &[]).unwrap(); + + let json = serde_json::to_value(&resolved).unwrap(); + let mut keys: Vec<&String> = json.as_object().unwrap().keys().collect(); + keys.sort(); + assert_eq!(keys, ["root", "scope", "slug"]); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn explicit_slug_resolves_from_registry() { let dir = test_dir("res_explicit"); @@ -261,6 +492,135 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn pre_existing_duplicate_slug_fails_instead_of_picking_one() { + let dir = test_dir("res_dupe"); + let first = dir.join("a/notes"); + let second = dir.join("b/notes"); + save_marker(&first, &marker("acme")).unwrap(); + save_marker(&second, &marker("acme")).unwrap(); + let registry = vec![ + WorkspaceEntry { + slug: "acme".into(), + path: first.clone(), + scope: WorkspaceScope::Project, + }, + WorkspaceEntry { + slug: "acme".into(), + path: second.clone(), + scope: WorkspaceScope::Project, + }, + ]; + + let err = resolve_workspace(Some("acme"), &[], &dir, &dir, ®istry).unwrap_err(); + assert_eq!(err.code, ErrorCode::Conflict); + for path in [&first, &second] { + assert!( + err.message.contains(&path.display().to_string()), + "message names every colliding workspace: {}", + err.message + ); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn duplicate_slug_resolves_when_only_one_workspace_still_exists() { + let dir = test_dir("res_dupe_stale"); + let live = dir.join("a/notes"); + save_marker(&live, &marker("acme")).unwrap(); + let registry = vec![ + WorkspaceEntry { + slug: "acme".into(), + path: dir.join("deleted/notes"), + scope: WorkspaceScope::Project, + }, + WorkspaceEntry { + slug: "acme".into(), + path: live.clone(), + scope: WorkspaceScope::Project, + }, + ]; + + let resolved = resolve_workspace(Some("acme"), &[], &dir, &dir, ®istry).unwrap(); + assert_eq!(resolved.root, live); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn registered_slug_shared_with_a_different_ambient_workspace_is_ambiguous() { + let dir = test_dir("res_dupe_ambient"); + let registered = dir.join("a/notes"); + let project = dir.join("b"); + save_marker(®istered, &marker("acme")).unwrap(); + save_marker(&project.join("notes"), &marker("acme")).unwrap(); + let registry = vec![WorkspaceEntry { + slug: "acme".into(), + path: registered, + scope: WorkspaceScope::Project, + }]; + + let err = resolve_workspace(Some("acme"), &[], &project, &dir.join("home"), ®istry) + .unwrap_err(); + assert_eq!(err.code, ErrorCode::Conflict); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn hand_edited_marker_beats_the_slug_the_registry_recorded() { + let dir = test_dir("res_marker_drift"); + let ws = dir.join("proj/notes"); + save_marker(&ws, &marker("renamed-by-hand")).unwrap(); + let registry = vec![WorkspaceEntry { + slug: "stale".into(), + path: ws.clone(), + scope: WorkspaceScope::Project, + }]; + + let resolved = + resolve_workspace(Some("renamed-by-hand"), &[], &dir, &dir, ®istry).unwrap(); + assert_eq!(resolved.root, ws); + assert_eq!( + resolve_workspace(Some("stale"), &[], &dir, &dir, ®istry) + .unwrap_err() + .code, + ErrorCode::WorkspaceNotFound, + "the folder no longer answers to the slug the registry recorded" + ); + assert_eq!( + available_slugs(®istry, None), + ["renamed-by-hand"], + "the advertised slug is the one the folder carries" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn one_folder_registered_under_two_spellings_is_not_ambiguous() { + let dir = test_dir("res_symlinked"); + let real = dir.join("real/notes"); + save_marker(&real, &marker("acme")).unwrap(); + let link = dir.join("link"); + std::os::unix::fs::symlink(dir.join("real"), &link).unwrap(); + let registry = vec![ + WorkspaceEntry { + slug: "acme".into(), + path: real.clone(), + scope: WorkspaceScope::Project, + }, + WorkspaceEntry { + slug: "acme".into(), + path: link.join("notes"), + scope: WorkspaceScope::Project, + }, + ]; + + let resolved = resolve_workspace(Some("acme"), &[], &dir, &dir, ®istry).unwrap(); + assert_eq!(resolved.root, real); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn explicit_unknown_slug_fails_loud_with_available_list() { let dir = test_dir("res_unknown"); diff --git a/src-tauri/mcp/src/server/elicit.rs b/src-tauri/mcp/src/server/elicit.rs index 4565e0a..6d87ce2 100644 --- a/src-tauri/mcp/src/server/elicit.rs +++ b/src-tauri/mcp/src/server/elicit.rs @@ -1,5 +1,5 @@ use docsreader_core::error::{CoreError, ErrorCode}; -use docsreader_core::workspace::resolve::ResolvedWorkspace; +use docsreader_core::workspace::resolve::{ResolvedWorkspace, WorkspaceOrigin, no_write_target}; use rmcp::model::{ElicitRequestParams, ElicitationAction, ElicitationSchema, EnumSchema}; use rmcp::service::ElicitationMode; use rmcp::{Peer, RoleServer}; @@ -22,13 +22,43 @@ pub(crate) async fn resolve_or_pick( if err.code != ErrorCode::WorkspaceNotFound { return Err(err); } - match pick_workspace(peer, explicit.unwrap_or_default()).await { + let requested = explicit.unwrap_or_default(); + match pick_workspace( + peer, + format!("Workspace {requested:?} was not found. Pick the workspace to use."), + ) + .await + { Some(slug) => resolve(Some(&slug)), None => Err(err), } } -async fn pick_workspace(peer: &Peer, requested: &str) -> Option { +/// A write whose resolution only fell back to the user default has no chosen +/// home, so it takes the same shape as an unknown slug: pick when the client +/// can elicit, refuse with recovery when it cannot. An explicit slug is a +/// deliberate choice even when it names the user workspace; only an un-slugged +/// write drifts there unnoticed. +pub(crate) async fn resolve_for_write( + peer: &Peer, + explicit: Option<&str>, +) -> Result { + let ws = resolve_or_pick(peer, explicit).await?; + if explicit.is_some() || ws.origin == WorkspaceOrigin::Found { + return Ok(ws); + } + match pick_workspace( + peer, + "No workspace was found for this location. Pick the workspace to write to.".to_string(), + ) + .await + { + Some(slug) => resolve(Some(&slug)), + None => Err(no_write_target(&known_slugs()?, &ws.root)), + } +} + +async fn pick_workspace(peer: &Peer, message: String) -> Option { if !peer .supported_elicitation_modes() .contains(&ElicitationMode::Form) @@ -41,7 +71,7 @@ async fn pick_workspace(peer: &Peer, requested: &str) -> Option, requested: &str) -> Option) -> Option { + // A fallback resolution is what put the picker on screen, so preselecting + // it would offer back the answer the caller is being asked to replace. let default = resolve(None) .ok() + .filter(|ws| ws.origin == WorkspaceOrigin::Found) .map(|ws| ws.slug) .filter(|slug| choices.contains(slug)); let mut choices = EnumSchema::builder(choices); diff --git a/src-tauri/mcp/src/server/memory_tools.rs b/src-tauri/mcp/src/server/memory_tools.rs index 28a8f48..84cb93b 100644 --- a/src-tauri/mcp/src/server/memory_tools.rs +++ b/src-tauri/mcp/src/server/memory_tools.rs @@ -8,8 +8,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::{ - DocsServer, TRUNCATION_GUIDANCE, client_name, doc_uri, ensure_workspace_exists, error_result, - resolve_or_pick, take_within_budget, + DocsServer, TRUNCATION_GUIDANCE, client_name, doc_uri, error_result, require_workspace_dir, + resolve_for_write, resolve_or_pick, take_within_budget, }; #[derive(Deserialize, JsonSchema)] @@ -22,7 +22,9 @@ pub struct WriteMemoryParams { pub content: String, /// Topic tags. pub tags: Option>, - /// Workspace slug (see list_workspaces). Omit to use the resolved default. + /// Workspace slug (see list_workspaces). Omit to use the workspace + /// resolved from the current project; when none resolves, the write is + /// refused instead of landing in the shared user workspace. pub workspace: Option, } @@ -80,8 +82,8 @@ impl DocsServer { ) -> Result, CallToolResult> { let agent = client_name(&peer); let result = async { - let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; - ensure_workspace_exists(&ws)?; + let ws = resolve_for_write(&peer, p.workspace.as_deref()).await?; + require_workspace_dir(&ws)?; let entry = write_memory_core( &ws.root, &p.topic, diff --git a/src-tauri/mcp/src/server/mod.rs b/src-tauri/mcp/src/server/mod.rs index dcc8585..c8683e2 100644 --- a/src-tauri/mcp/src/server/mod.rs +++ b/src-tauri/mcp/src/server/mod.rs @@ -10,8 +10,6 @@ mod write_tools; use std::path::PathBuf; use docsreader_core::error::{CoreError, ErrorCode}; -use docsreader_core::workspace::WorkspaceScope; -use docsreader_core::workspace::init::init_workspace_core; use docsreader_core::workspace::registry::{default_registry_path, load_registry}; use docsreader_core::workspace::resolve::{ResolvedWorkspace, available_slugs, resolve_workspace}; use docsreader_core::write::DocStatus; @@ -23,7 +21,7 @@ use rmcp::model::{ use rmcp::service::RequestContext; use rmcp::{ErrorData, Peer, RoleServer, ServerHandler, prompt_handler, tool_handler}; -pub(crate) use elicit::resolve_or_pick; +pub(crate) use elicit::{resolve_for_write, resolve_or_pick}; pub struct DocsServer; @@ -125,30 +123,19 @@ pub(crate) fn known_slugs() -> Result, CoreError> { Ok(available_slugs(®istry, ambient.as_ref())) } -/// The default user workspace is create-on-first-use: writing to it before -/// init_workspace must succeed, so agents never hit a setup wall. -pub(crate) fn ensure_workspace_exists(ws: &ResolvedWorkspace) -> Result<(), CoreError> { +/// Only init_workspace creates a workspace, so a write never conjures the +/// folder it lands in; this is the last guard before touching disk. +pub(crate) fn require_workspace_dir(ws: &ResolvedWorkspace) -> Result<(), CoreError> { if ws.root.is_dir() { return Ok(()); } - if ws.scope != WorkspaceScope::User { - return Err(CoreError::new( - ErrorCode::WorkspaceNotFound, - format!("workspace directory {} is missing", ws.root.display()), - ) - .with_recovery( - "call list_workspaces to see valid slugs, or init_workspace to create one", - )); - } - let home = home_dir()?; - init_workspace_core( - &ws.root, - Some(&ws.slug), - None, - WorkspaceScope::User, - &default_registry_path(&home), - )?; - Ok(()) + Err(CoreError::new( + ErrorCode::WorkspaceNotFound, + format!("workspace directory {} is missing", ws.root.display()), + ) + .with_recovery( + "call list_workspaces to see which workspaces exist, or init_workspace to create one", + )) } pub(crate) fn doc_uri(ws_slug: &str, rel_path: &str) -> String { diff --git a/src-tauri/mcp/src/server/onboarding.md b/src-tauri/mcp/src/server/onboarding.md index e4d03ad..caf35bf 100644 --- a/src-tauri/mcp/src/server/onboarding.md +++ b/src-tauri/mcp/src/server/onboarding.md @@ -2,13 +2,17 @@ DocsReader is a local markdown store you write to over MCP while humans read the same files in the DocsReader app. Prefer these tools over raw file writes: they -handle slugs, frontmatter, collisions, lifecycle moves, and git staging. +handle slugs, frontmatter, collisions, lifecycle moves, and git staging. Staging +runs only when the workspace folder sits inside a git repository, and it only +adds the file just written: never a commit, never anything outside the +workspace folder. ## Model - A workspace is a folder of markdown docs. Default user workspace: `~/notes` - (created on first write). A project can opt in with its own `/notes` - via `init_workspace`; when one exists it takes precedence. + (created by `init_workspace`, never by a write). A project workspace is + `/notes`; when one exists it takes precedence. See "Choosing a + workspace". - Docs live in the folder matching their lifecycle status: `research/`, `in-progress/`, `done/`, `archived/`. The folder IS the status. - Optional phase subfolders group work inside a status, e.g. @@ -16,6 +20,43 @@ handle slugs, frontmatter, collisions, lifecycle moves, and git staging. - Filenames are slugs generated from titles. Frontmatter carries title, tags, owner, created_at, created_by - never status or phase. +## Choosing a workspace + +Each project gets its own workspace. Labels and tags group work inside one +workspace; they do not separate projects, and `list_tasks` in a shared +workspace returns other projects' tasks too. + +List first, reuse second, create last: `list_workspaces` before writing, and +if one already belongs to this project, use it. `init_workspace` answering +"already a DocsReader workspace" means the same thing - that workspace is +ready, keep writing there rather than looking for somewhere else. + +When asked to track work for a project that has no workspace yet, take the +first option that fits and proceed without asking: + +1. `init_workspace {path: "", name: ""}`, + creating `/notes`. The project root being a git repository is not + a blocker: only the `notes` folder is written, and files there are staged + but never committed. +2. If the project's own tree must stay untouched, use a sibling folder: + `init_workspace {path: "/-notes"}`. Pass that workspace's + slug on every later call, since it is outside the project tree and will not + be picked up automatically. +3. `~/notes` only for work that belongs to no project. Do not park a new + project's docs or tasks there. + +A write with no `workspace` argument is refused when nothing at the agent's +location resolves to a workspace, rather than falling back to `~/notes`. An +already set up `~/notes` does not change that: it takes un-slugged writes only +from an agent working inside it, since from anywhere else it names no project. +The refusal lists the workspaces that do exist, and `workspace` is always +honoured, `notes` included. Reads still fall back, so they never need setup. + +The `name` is what humans pick from in the app, so it must identify the +project or product: `"Acme Billing API"`, never `"Notes"` or `"Docs"`. Leave +`slug` to its default (the project folder name) unless `list_workspaces` +already shows that slug in use. + ## Workflow 1. `list_workspaces` shows what exists; `init_workspace` creates one. diff --git a/src-tauri/mcp/src/server/task_tools.rs b/src-tauri/mcp/src/server/task_tools.rs index 34bbfb7..d78e573 100644 --- a/src-tauri/mcp/src/server/task_tools.rs +++ b/src-tauri/mcp/src/server/task_tools.rs @@ -10,8 +10,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::{ - DocsServer, TRUNCATION_GUIDANCE, client_name, doc_uri, ensure_workspace_exists, error_result, - resolve_or_pick, take_within_budget, + DocsServer, TRUNCATION_GUIDANCE, client_name, doc_uri, error_result, require_workspace_dir, + resolve_for_write, resolve_or_pick, take_within_budget, }; #[derive(Deserialize, JsonSchema)] @@ -32,7 +32,9 @@ pub struct WriteTaskParams { pub labels: Option>, /// Ids of tasks this one depends on, e.g. ["task-2"]. pub dependencies: Option>, - /// Workspace slug (see list_workspaces). Omit to use the resolved default. + /// Workspace slug (see list_workspaces). Omit to use the workspace + /// resolved from the current project; when none resolves, the write is + /// refused instead of landing in the shared user workspace. pub workspace: Option, } @@ -112,7 +114,7 @@ fn task_change(ws: ResolvedWorkspace, task: TaskSummary) -> Json Result, CallToolResult> { let reporter = client_name(&peer); let result = async { - let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; - ensure_workspace_exists(&ws)?; + let ws = resolve_for_write(&peer, p.workspace.as_deref()).await?; + require_workspace_dir(&ws)?; let task = NewTask { title: &p.title, description: &p.description, @@ -145,7 +147,7 @@ impl DocsServer { } #[tool( - description = "List tasks in the workspace, ordered by id. Filter by status (\"To Do\" | \"In Progress\" | \"Done\") or label.", + description = "List tasks in the workspace, ordered by id. Filter by status (\"To Do\" | \"In Progress\" | \"Done\") or label. Labels group work inside one workspace; unfiltered results include every project sharing it.", annotations(read_only_hint = true) )] async fn list_tasks( @@ -189,7 +191,7 @@ impl DocsServer { Parameters(p): Parameters, ) -> Result, CallToolResult> { let result = async { - let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + let ws = resolve_for_write(&peer, p.workspace.as_deref()).await?; let task = set_task_status_core(&ws.root, &p.id, &p.status).await?; Ok::<_, CoreError>((ws, task)) } @@ -208,7 +210,7 @@ impl DocsServer { Parameters(p): Parameters, ) -> Result, CallToolResult> { let result = async { - let ws = resolve_or_pick(&peer, p.workspace.as_deref()).await?; + let ws = resolve_for_write(&peer, p.workspace.as_deref()).await?; let task = update_task_core( &ws.root, &p.id, diff --git a/src-tauri/mcp/src/server/workspace_tools.rs b/src-tauri/mcp/src/server/workspace_tools.rs index 8222d25..8711cbf 100644 --- a/src-tauri/mcp/src/server/workspace_tools.rs +++ b/src-tauri/mcp/src/server/workspace_tools.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use docsreader_core::error::{CoreError, ErrorCode}; use docsreader_core::workspace::WorkspaceScope; use docsreader_core::workspace::init::{InitializedWorkspace, init_workspace_core}; -use docsreader_core::workspace::registry::{default_registry_path, load_registry}; +use docsreader_core::workspace::registry::{default_registry_path, live_workspaces, load_registry}; use docsreader_core::workspace::resolve::DEFAULT_WORKSPACE_DIR; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::CallToolResult; @@ -21,9 +21,14 @@ pub struct InitWorkspaceParams { /// Workspace scope: "user" (~/notes) or "project" (/notes). /// Defaults to project when path is given, user otherwise. pub scope: Option, - /// Workspace slug; defaults to the project folder name, or "notes" for user scope. + /// Workspace slug; omit it to have one derived from the project folder + /// name ("notes" for user scope), suffixed if that name is taken and + /// reported back as "slug" - use that value on later calls. A slug + /// list_workspaces already shows is refused, so work cannot land in + /// another project's workspace. pub slug: Option, - /// Human-readable display name. + /// Display name humans pick from in the app: the project or product this + /// workspace holds, e.g. "Acme Billing API" - never "Notes" or "Docs". pub name: Option, } @@ -38,7 +43,7 @@ impl DocsServer { } #[tool( - description = "List all known DocsReader workspaces: registered project workspaces plus the default user workspace (~/notes). Call this when a workspace slug is unknown or before choosing where to write.", + description = "List all known DocsReader workspaces: registered project workspaces plus the default user workspace (~/notes). Call this when a workspace slug is unknown, before choosing where to write, and before init_workspace. If none of them belongs to the project at hand, create one with init_workspace instead of writing into an unrelated workspace.", annotations(read_only_hint = true) )] async fn list_workspaces(&self) -> CallToolResult { @@ -49,7 +54,7 @@ impl DocsServer { } #[tool( - description = "Create a new DocsReader workspace and register it. No args: creates the user workspace at ~/notes. With path: creates a project workspace at /notes. Fails if the target already has content.", + description = "Create a new DocsReader workspace and register it. Call list_workspaces first and reuse the one that belongs to this project; create only when none does. No args: creates the user workspace at ~/notes. With path: creates a project workspace at /notes. Give every project its own workspace rather than sharing ~/notes. Set name to the project or product the workspace holds, e.g. \"Acme Billing API\" - never \"Notes\" or \"Docs\", which tell a human nothing once a second workspace exists. A git repository is a valid location: only the notes folder is written. If /notes is already a workspace, it is ready to use: pass its slug and keep writing there. Always read the returned \"slug\" and use it on later calls: an omitted slug is derived from the folder name and gets a suffix when that name is already taken. It fails when /notes already holds non-workspace files - then either point path at a sibling folder such as /-notes, or convert the folder in the DocsReader app - and when an explicit slug already belongs to another workspace, which would otherwise send this project's work there.", annotations(destructive_hint = false, idempotent_hint = true) )] async fn init_workspace( @@ -71,7 +76,7 @@ impl DocsServer { fn list_workspaces_impl() -> Result { let home = home_dir()?; - let entries = load_registry(&default_registry_path(&home))?; + let entries = live_workspaces(load_registry(&default_registry_path(&home))?); let default_root = home.join(DEFAULT_WORKSPACE_DIR); Ok(serde_json::json!({ "workspaces": entries, diff --git a/src-tauri/mcp/src/server/write_tools.rs b/src-tauri/mcp/src/server/write_tools.rs index f90cd6d..8e3ca20 100644 --- a/src-tauri/mcp/src/server/write_tools.rs +++ b/src-tauri/mcp/src/server/write_tools.rs @@ -18,8 +18,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::{ - DocsServer, client_name, doc_resource_link, doc_uri, ensure_workspace_exists, error_result, - resolve_or_pick, + DocsServer, client_name, doc_resource_link, doc_uri, error_result, require_workspace_dir, + resolve_for_write, }; #[derive(Deserialize, JsonSchema)] @@ -30,8 +30,9 @@ pub struct WriteDocParams { pub body: String, /// Lifecycle status: one of "research", "in-progress", "done", "archived". pub status: String, - /// Workspace slug (see list_workspaces). Omit to use the resolved default - /// workspace (project ./notes if present, else user ~/notes). + /// Workspace slug (see list_workspaces). Omit to use the workspace + /// resolved from the current project; when none resolves, the write is + /// refused instead of landing in the shared user workspace. pub workspace: Option, /// Phase subfolder within the status folder, e.g. "discovery" or "v2-launch". pub phase: Option, @@ -139,15 +140,15 @@ async fn located( peer: &Peer, workspace: Option<&str>, ) -> Result { - let ws = resolve_or_pick(peer, workspace).await?; - ensure_workspace_exists(&ws)?; + let ws = resolve_for_write(peer, workspace).await?; + require_workspace_dir(&ws)?; Ok(ws) } #[tool_router(router = write_tool_router, vis = "pub(crate)")] impl DocsServer { #[tool( - description = "Create a markdown doc in a DocsReader workspace. The doc lands in the folder matching its status (research | in-progress | done | archived), optionally inside a phase subfolder, with generated frontmatter. Prefer this over writing files directly: it handles slugs, collisions, metadata, and git staging.", + description = "Create a markdown doc in a DocsReader workspace. The doc lands in the folder matching its status (research | in-progress | done | archived), optionally inside a phase subfolder, with generated frontmatter. Prefer this over writing files directly: it handles slugs, collisions, metadata, and git staging (the written file is added when the workspace is inside a repository, never committed).", annotations(destructive_hint = false) )] async fn write_doc( diff --git a/src-tauri/mcp/tests/stdio.rs b/src-tauri/mcp/tests/stdio.rs index ef625df..acef862 100644 --- a/src-tauri/mcp/tests/stdio.rs +++ b/src-tauri/mcp/tests/stdio.rs @@ -17,10 +17,17 @@ struct McpClient { impl McpClient { fn spawn(home: &Path, envs: &[(&str, &str)], capabilities: Value) -> Self { + Self::spawn_in(home, home, envs, capabilities) + } + + fn spawn_in(cwd: &Path, home: &Path, envs: &[(&str, &str)], capabilities: Value) -> Self { let mut child = Command::new(env!("CARGO_BIN_EXE_docsreader-mcp")) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) + // The walk-up from cwd would otherwise reach the developer's own + // workspaces, so an un-slugged call would leave the sandbox. + .current_dir(cwd) .env("HOME", home) .envs(envs.iter().copied()) .spawn() @@ -441,3 +448,410 @@ fn elicitation_decline_falls_back_to_recovery_error() { assert!(is_err); assert_eq!(payload["error"]["code"], "workspace_not_found"); } + +#[test] +fn list_workspaces_hides_a_workspace_whose_folder_is_gone() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + let kept = init_project(&mut c, home.path(), "kept"); + let deleted = init_project(&mut c, home.path(), "deleted"); + std::fs::remove_dir_all(&deleted).unwrap(); + + let (payload, is_err) = c.call("list_workspaces", json!({})); + assert!(!is_err, "{payload}"); + let slugs: Vec<&str> = payload["workspaces"] + .as_array() + .unwrap() + .iter() + .filter_map(|w| w["slug"].as_str()) + .collect(); + assert_eq!(slugs, ["kept"], "deleted workspace still advertised"); + assert!(Path::new(&kept).is_dir()); +} + +#[test] +fn list_workspaces_reports_a_hand_edited_marker_slug() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + let project = init_project(&mut c, home.path(), "before"); + std::fs::write( + Path::new(&project).join("notes/.docsreader.yaml"), + "slug: after\n", + ) + .unwrap(); + + let (payload, is_err) = c.call("list_workspaces", json!({})); + assert!(!is_err, "{payload}"); + assert_eq!(payload["workspaces"][0]["slug"], "after"); + + let (docs, is_err) = c.call("list_docs", json!({"workspace": "after"})); + assert!(!is_err, "the advertised slug must resolve: {docs}"); +} + +#[test] +fn init_refuses_a_second_workspace_at_the_same_root_and_a_taken_slug() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + let project = init_project(&mut c, home.path(), "acme-billing"); + let elsewhere = home.path().join("unrelated"); + std::fs::create_dir_all(&elsewhere).unwrap(); + + let (payload, is_err) = c.call("init_workspace", json!({"path": project.as_str()})); + assert!(is_err, "re-init must not duplicate a workspace: {payload}"); + assert_eq!(payload["error"]["code"], "conflict"); + assert!( + payload["error"]["recovery"] + .as_str() + .unwrap() + .contains("this is the workspace to use"), + "recovery must send the caller back to the existing workspace: {payload}" + ); + + let (payload, is_err) = c.call( + "init_workspace", + json!({"path": elsewhere.to_str().unwrap(), "slug": "acme-billing"}), + ); + assert!(is_err, "a taken slug must be refused: {payload}"); + assert_eq!(payload["error"]["code"], "conflict"); + assert!( + payload["error"]["message"] + .as_str() + .unwrap() + .contains(Path::new(&project).join("notes").to_str().unwrap()), + "the refusal names the workspace already holding the slug: {payload}" + ); + assert!( + !elsewhere.join("notes").exists(), + "the refused workspace must not be created" + ); + + let (list, _) = c.call("list_workspaces", json!({})); + let slugs: Vec<&str> = list["workspaces"] + .as_array() + .unwrap() + .iter() + .filter_map(|w| w["slug"].as_str()) + .collect(); + assert_eq!(slugs, ["acme-billing"], "no duplicate registration"); +} + +#[test] +fn workspace_tool_descriptions_tell_a_caller_to_list_first_and_name_the_project() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + let listed = c.request("tools/list", json!({}), no_server_requests); + let description = |name: &str| -> String { + listed["tools"] + .as_array() + .unwrap() + .iter() + .find(|t| t["name"] == json!(name)) + .unwrap_or_else(|| panic!("{name} is advertised"))["description"] + .as_str() + .unwrap_or_else(|| panic!("{name} carries a description")) + .to_string() + }; + + let init = description("init_workspace"); + assert!( + init.contains("Call list_workspaces first"), + "init_workspace must send the caller to the listing first: {init}" + ); + assert!( + init.contains("never \"Notes\" or \"Docs\""), + "init_workspace must rule out a generic name: {init}" + ); + assert!( + init.contains("already a workspace"), + "init_workspace must say an existing workspace is the one to use: {init}" + ); + + let list = description("list_workspaces"); + assert!( + list.contains("before init_workspace"), + "list_workspaces must place itself ahead of creation: {list}" + ); + + let schema = listed["tools"] + .as_array() + .unwrap() + .iter() + .find(|t| t["name"] == json!("init_workspace")) + .unwrap()["inputSchema"]["properties"]["name"]["description"] + .as_str() + .unwrap() + .to_string(); + assert!( + schema.contains("project or product"), + "the name field must ask for a project-identifying name: {schema}" + ); +} + +#[test] +fn a_write_from_inside_the_user_workspace_needs_no_slug() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + init_project(&mut c, home.path(), "notes"); + + let (created, is_err) = c.call("init_workspace", json!({})); + assert!(!is_err, "{created}"); + let slug = created["slug"].as_str().unwrap().to_string(); + assert_ne!( + slug, "notes", + "the project workspace already holds that slug" + ); + + // Working inside the user workspace, with a client that could be asked to + // pick: it must never be, or every personal note costs an interruption. + let inside = home.path().join("notes/areas"); + std::fs::create_dir_all(&inside).unwrap(); + let mut c = McpClient::spawn_in(&inside, home.path(), &[], json!({"elicitation": {}})); + + let (doc, is_err) = c.call( + "write_doc", + json!({"title": "First", "body": "# First", "status": "research"}), + ); + assert!( + !is_err, + "a caller standing in the user workspace means it: {doc}" + ); + assert_eq!(doc["workspace"]["slug"], json!(slug)); + + let (list, is_err) = c.call("list_docs", json!({"workspace": slug})); + assert!(!is_err, "{list}"); + assert_eq!( + list["docs"].as_array().unwrap().len(), + 1, + "the reported slug must name the workspace the doc was written into" + ); +} + +#[test] +fn a_set_up_user_workspace_does_not_absorb_a_write_from_an_unrelated_folder() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + let (created, is_err) = c.call("init_workspace", json!({})); + assert!(!is_err, "{created}"); + let user_slug = created["slug"].as_str().unwrap().to_string(); + let elsewhere = home.path().join("unrelated-project/src"); + std::fs::create_dir_all(&elsewhere).unwrap(); + + let mut c = McpClient::spawn_in(&elsewhere, home.path(), &[], json!({})); + let (payload, is_err) = c.call( + "write_doc", + json!({"title": "Plan", "body": "# Plan", "status": "research"}), + ); + assert!( + is_err, + "an existing ~/notes is not a reason to file unrelated work there: {payload}" + ); + assert_eq!(payload["error"]["code"], "workspace_not_found"); + let recovery = payload["error"]["recovery"].as_str().unwrap(); + assert!( + recovery.contains(&user_slug), + "the refusal must name the workspace a retry could use: {recovery}" + ); + assert!( + !recovery.contains("personal notes"), + "the personal notes already exist, so offering to create them misleads: {recovery}" + ); + + let (list, is_err) = c.call("list_docs", json!({"workspace": user_slug.clone()})); + assert!(!is_err, "{list}"); + assert_eq!( + list["docs"].as_array().unwrap().len(), + 0, + "nothing may have landed in the user workspace" + ); + + // The refusal is about writes only: a read from the same folder keeps + // answering from the user workspace, as it did before this policy. + for tool in ["list_docs", "search_memory", "list_tasks"] { + let (payload, is_err) = c.call(tool, json!({})); + assert!(!is_err, "{tool} must still resolve: {payload}"); + assert_eq!(payload["workspace"]["slug"], json!(user_slug), "{tool}"); + } + + let (doc, is_err) = c.call( + "write_doc", + json!({"title": "Plan", "body": "# Plan", "status": "research", "workspace": user_slug}), + ); + assert!( + !is_err, + "naming the workspace stays the one-word way through: {doc}" + ); +} + +#[test] +fn an_un_slugged_write_from_inside_a_project_lands_in_that_projects_workspace() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + let project = init_project(&mut c, home.path(), "acme-billing"); + let (created, is_err) = c.call("init_workspace", json!({})); + assert!(!is_err, "{created}"); + let nested = Path::new(&project).join("src/deeply/nested"); + std::fs::create_dir_all(&nested).unwrap(); + + // A client that could be asked must not be: the project workspace above + // the caller is the answer, and asking would make every write a prompt. + let mut c = McpClient::spawn_in(&nested, home.path(), &[], json!({"elicitation": {}})); + let (doc, is_err) = c.call( + "write_doc", + json!({"title": "Billing Design", "body": "# Billing Design", "status": "research"}), + ); + assert!(!is_err, "the project workspace covers the caller: {doc}"); + assert_eq!(doc["workspace"]["slug"], "acme-billing"); + assert_eq!(doc["workspace"]["scope"], "project"); + + let (list, _) = c.call("list_docs", json!({"workspace": "acme-billing"})); + assert_eq!(list["docs"].as_array().unwrap().len(), 1); + let (shared, _) = c.call("list_docs", json!({"workspace": "notes"})); + assert_eq!( + shared["docs"].as_array().unwrap().len(), + 0, + "the shared workspace must stay out of it: {shared}" + ); +} + +#[test] +fn an_un_slugged_write_in_a_clean_session_is_refused_not_absorbed_into_user_notes() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + + let (listed, _) = c.call("list_workspaces", json!({})); + assert_eq!(listed["defaultUserWorkspace"]["exists"], false); + + let writes = [ + ( + "write_doc", + json!({"title": "Plan", "body": "# Plan", "status": "research"}), + ), + ( + "write_task", + json!({"title": "Wire CI", "description": "Add the pipeline"}), + ), + ( + "write_memory", + json!({"topic": "deploy target", "content": "staging"}), + ), + ]; + for (tool, args) in writes { + let (payload, is_err) = c.call(tool, args); + assert!(is_err, "{tool} must refuse without a workspace: {payload}"); + assert_eq!(payload["error"]["code"], "workspace_not_found", "{tool}"); + assert!( + payload["error"]["message"] + .as_str() + .unwrap() + .contains("names a workspace to write to"), + "{tool} must refuse on the missing choice, not on a missing folder: {payload}" + ); + let recovery = payload["error"]["recovery"].as_str().unwrap(); + assert!( + recovery.contains("init_workspace"), + "{tool} recovery must name the way out: {recovery}" + ); + } + + assert!( + !home.path().join("notes").exists(), + "the refused writes must not create the user workspace" + ); + let (listed, _) = c.call("list_workspaces", json!({})); + assert_eq!(listed["workspaces"].as_array().unwrap().len(), 0); + assert_eq!(listed["defaultUserWorkspace"]["exists"], false); +} + +#[test] +fn reads_in_a_clean_session_still_answer_from_the_user_workspace() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({})); + + for (tool, key) in [("search_memory", "memories"), ("list_tasks", "tasks")] { + let (payload, is_err) = c.call(tool, json!({})); + assert!(!is_err, "{tool} must keep working: {payload}"); + assert_eq!(payload["workspace"]["slug"], "notes", "{tool}"); + assert_eq!(payload[key].as_array().unwrap().len(), 0, "{tool}"); + } + + // list_docs already reported the empty folder before this policy; it must + // still be that answer and not the write refusal. + let (payload, is_err) = c.call("list_docs", json!({})); + assert!(is_err, "{payload}"); + assert!( + payload["error"]["message"] + .as_str() + .unwrap() + .contains("is missing"), + "reads keep their own error, not the write refusal: {payload}" + ); + + assert!( + !home.path().join("notes").exists(), + "a read must not create the user workspace" + ); +} + +#[test] +fn an_un_slugged_write_offers_the_picker_and_lands_in_the_picked_workspace() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({"elicitation": {}})); + init_project(&mut c, home.path(), "chosen"); + // A bare ~/notes folder carries no marker, so it is still a fallback while + // being a slug the picker can offer. + std::fs::create_dir_all(home.path().join("notes")).unwrap(); + + let mut picker = None; + let (doc, is_err) = c.call_with( + "write_doc", + json!({"title": "Plan", "body": "# Plan", "status": "research"}), + |msg| { + assert_eq!(msg["method"], "elicitation/create"); + picker = Some(msg["params"].clone()); + json!({"action": "accept", "content": {"workspace": "chosen"}}) + }, + ); + assert!(!is_err, "{doc}"); + assert_eq!(doc["workspace"]["slug"], "chosen"); + assert_eq!( + std::fs::read_dir(home.path().join("notes")) + .unwrap() + .count(), + 0, + "nothing may land in the user folder that was not picked" + ); + + let picker = picker.expect("server asked which workspace to write to"); + let field = &picker["requestedSchema"]["properties"]["workspace"]; + assert!( + field["enum"].as_array().unwrap().contains(&json!("chosen")), + "{picker}" + ); + assert!( + field.get("default").is_none(), + "the fallback must not be preselected as the answer: {picker}" + ); +} + +#[test] +fn declining_the_picker_refuses_the_un_slugged_write_with_the_workspaces_that_exist() { + let home = temp_home(); + let mut c = McpClient::spawn(home.path(), &[], json!({"elicitation": {}})); + init_project(&mut c, home.path(), "existing"); + + let (payload, is_err) = c.call_with( + "write_doc", + json!({"title": "Plan", "body": "# Plan", "status": "research"}), + |_| json!({"action": "decline"}), + ); + assert!(is_err, "{payload}"); + assert_eq!(payload["error"]["code"], "workspace_not_found"); + assert!( + payload["error"]["recovery"] + .as_str() + .unwrap() + .contains("existing"), + "the refusal must name the workspaces a retry could use: {payload}" + ); + assert!(!home.path().join("notes").exists()); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4b725ec..6d00310 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,8 +32,10 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .manage(OpenedPaths::default()) + .manage(tauri_api::SearchGeneration::default()) .invoke_handler(tauri::generate_handler![ tauri_api::scan_markdown, + tauri_api::search_content, tauri_api::convert_workspace, tauri_api::detect_agent_clients, tauri_api::connect_agent_client, diff --git a/src-tauri/src/tauri_api/mod.rs b/src-tauri/src/tauri_api/mod.rs index 3c16e42..cbe864b 100644 --- a/src-tauri/src/tauri_api/mod.rs +++ b/src-tauri/src/tauri_api/mod.rs @@ -1,15 +1,21 @@ +use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; use tauri::path::BaseDirectory; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::{AppHandle, Emitter, Manager, State}; use crate::agents::{self, AgentClient, ClientId}; use docsreader_core::git::{git_show_head_core, git_status_core, GitStatus}; use docsreader_core::scan::{run_scan, ScanProgress, ScanProgressSink, ScanResult}; +use docsreader_core::search::{ + search_roots, ContentQuery, ContentSearchResult, SearchAbort, SearchScope, +}; use docsreader_core::tasks::{list_tasks_core, set_task_status_core, TaskSummary}; use docsreader_core::workspace::init::{convert_workspace_core, InitializedWorkspace}; use docsreader_core::workspace::registry::{ - default_registry_path, existing_workspaces, load_registry, WorkspaceEntry, + default_registry_path, live_workspaces, load_registry, WorkspaceEntry, }; const PROGRESS_EVENT: &str = "scan-progress"; @@ -33,6 +39,57 @@ pub async fn scan_markdown(app: AppHandle, path: String) -> Result>>); + +impl SearchGeneration { + fn claim(&self, surface: &str) -> NewerQueryWins { + let latest = { + let mut counters = self.0.lock().unwrap_or_else(PoisonError::into_inner); + counters.entry(surface.to_string()).or_default().clone() + }; + let generation = latest.fetch_add(1, Ordering::SeqCst) + 1; + NewerQueryWins { generation, latest } + } +} + +struct NewerQueryWins { + generation: u64, + latest: Arc, +} + +impl SearchAbort for NewerQueryWins { + fn is_aborted(&self) -> bool { + self.latest.load(Ordering::Relaxed) != self.generation + } +} + +#[tauri::command] +pub async fn search_content( + state: State<'_, SearchGeneration>, + paths: Vec, + query: String, + scope: Option, + surface: Option, +) -> Result { + let abort = state.claim(&surface.unwrap_or_default()); + + tauri::async_runtime::spawn_blocking(move || { + let Some(parsed) = ContentQuery::parse(&query, false, scope.unwrap_or_default()) else { + return Ok(ContentSearchResult::empty()); + }; + let roots: Vec = paths.into_iter().map(PathBuf::from).collect(); + Ok(search_roots(&roots, &parsed, &abort)) + }) + .await + .map_err(|e| format!("search task panicked: {e}"))? +} + #[tauri::command] pub async fn convert_workspace( app: AppHandle, @@ -65,7 +122,7 @@ pub fn connect_agent_client(app: AppHandle, id: ClientId) -> Result Result, String> { let home = home_dir(&app)?; let entries = load_registry(&default_registry_path(&home)).map_err(|e| e.message)?; - Ok(existing_workspaces(entries)) + Ok(live_workspaces(entries)) } #[tauri::command] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3da972a..3d56a52 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,11 +13,16 @@ "windows": [ { "title": "", + "dragDropEnabled": false, "width": 1280, "height": 820, "minWidth": 800, "minHeight": 500, - "titleBarStyle": "Overlay" + "titleBarStyle": "Overlay", + "trafficLightPosition": { + "x": 11, + "y": 20 + } } ], "security": { @@ -74,7 +79,7 @@ "icons/icon.ico" ], "macOS": { - "minimumSystemVersion": "10.15", + "minimumSystemVersion": "11.0", "entitlements": null, "exceptionDomain": null, "frameworks": [] diff --git a/src/App.tsx b/src/App.tsx index 34b3024..de215cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,6 @@ import { lazy, Suspense, useCallback, useDeferredValue, useEffect, useMemo, useR import { invoke } from "@tauri-apps/api/core"; import { readTextFile } from "@tauri-apps/plugin-fs"; import { message } from "@tauri-apps/plugin-dialog"; -import { Columns2, ListCollapse, ListTree, Moon, PanelLeft, RefreshCw, Rows2, Search, Settings as SettingsIcon, Square, Sun } from "lucide-react"; import type { QuickOpenFile } from "@/components/quickopen/QuickOpenDialog"; import type { SettingsSection } from "@/components/settings/SettingsDialog"; import { BacklinksPanel } from "@/components/document/BacklinksPanel"; @@ -10,8 +9,6 @@ import { OutlinePanel } from "@/components/document/OutlinePanel"; import { matchShortcut, parseShortcut } from "@/lib/shortcuts"; const QuickOpenDialog = lazy(() => import("@/components/quickopen/QuickOpenDialog")); -import { Button } from "@/components/ui/button"; -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { TooltipProvider } from "@/components/ui/tooltip"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { @@ -20,16 +17,18 @@ import { ResizablePanelGroup, } from "@/components/ui/resizable"; import { ExplorerSidebar } from "@/components/explorer/ExplorerSidebar"; +import { AppToolbar } from "@/components/layout/AppToolbar"; import { ConvertWorkspacePrompt } from "@/components/explorer/ConvertWorkspacePrompt"; -import { PathBreadcrumb } from "@/components/document/PathBreadcrumb"; import { PaneView } from "@/components/document/PaneView"; import { UpdateToast } from "@/components/document/UpdateToast"; const SettingsDialog = lazy(() => import("@/components/settings/SettingsDialog")); import { useLibrary } from "@/hooks/useLibrary"; +import { useContentSearch } from "@/hooks/useContentSearch"; +import { useSidebarSearch } from "@/hooks/useSidebarSearch"; +import { mergeSearchEntries } from "@/lib/searchEntries"; import { useConvertPrompt } from "@/hooks/useConvertPrompt"; import { usePanes } from "@/hooks/usePanes"; -import type { SplitMode } from "@/lib/storage"; import { useTheme } from "@/hooks/useTheme"; import { useViewSettings } from "@/hooks/useViewSettings"; import { useSidebarState } from "@/hooks/useSidebarState"; @@ -44,9 +43,18 @@ import { fetchGitHead, type GitFileStatusKind } from "@/lib/git"; import { parseFrontmatter } from "@/lib/scan"; import { DiffViewerDialog } from "@/components/document/DiffViewerDialog"; import type { MarkdownFile } from "@/lib/scan"; +import { CHROME_STYLE } from "@/components/layout/chrome"; +import type { SplitMode, TaskTabView } from "@/lib/storage"; +import { explorerOpen, fileTarget, TAB_KIND_SPECS, TASKS_TARGET } from "@/lib/tabKinds"; import "@/styles/code-theme.css"; -const CHROME_ICON = "size-6 text-muted-foreground hover:text-foreground [&>svg]:size-4"; +// "off" never reaches the split branch, but the map stays total so a new +// split mode has to declare its orientation instead of silently defaulting. +const PANE_ORIENTATION: Record = { + off: "horizontal", + horizontal: "horizontal", + vertical: "vertical", +}; function App() { const library = useLibrary(); @@ -73,11 +81,27 @@ function App() { roots: library.roots, addRoot: library.addRoot, selectRoot: library.selectRoot, - openFile: panes.openInActivePane, + openFile: (path: string) => panes.openInActivePane(fileTarget(path)), }); useTheme(viewSettings.settings.colorScheme, viewSettings.settings.accentColor); const deferredSettings = useDeferredValue(viewSettings.settings); - const [search, setSearch] = useState(""); + const setSidebarOpen = sidebar.setOpen; + // A tab that is itself a view of the workspace hides the explorer without + // touching the remembered preference, so an ordinary document still opens + // the way the reader left it. Asking for the explorer while such a tab is + // showing is answered here instead. + const [explorerShownBeside, setExplorerShownBeside] = useState(false); + const revealSidebar = useCallback(() => { + setSidebarOpen(true); + setExplorerShownBeside(true); + }, [setSidebarOpen]); + // Mirrors the split every editor uses: Cmd+F searches the open document, + // Shift+Cmd+F searches the sidebar. Find-in-document owns Cmd+F in + // TabScrollPane, so the two never contend for the same chord. + const search = useSidebarSearch({ + shortcut: viewSettings.settings.workspaceSearchShortcut, + onReveal: revealSidebar, + }); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsMounted, setSettingsMounted] = useState(false); const [settingsSection, setSettingsSection] = useState(); @@ -96,6 +120,23 @@ function App() { setScrollElByPane(([a, _]) => [a, el]); }, []); + const openInActive = tabs.openInActive; + const openInNew = tabs.openInNew; + const openInOtherPane = panes.openInOtherPane; + const openFile = useCallback( + (path: string) => openInActive(fileTarget(path)), + [openInActive] + ); + const openFileInNewTab = useCallback( + (path: string) => openInNew(fileTarget(path)), + [openInNew] + ); + const openFileInOtherPane = useCallback( + (path: string) => openInOtherPane(fileTarget(path)), + [openInOtherPane] + ); + const openTasksTab = useCallback(() => openInActive(TASKS_TARGET), [openInActive]); + const toggleOutline = useCallback(() => { viewSettings.update({ ...viewSettings.settings, @@ -152,7 +193,7 @@ function App() { if (!file) return; const hasTabInRoot = tabsList.some( - (t) => t.path.startsWith(root + "/") || t.path === root + (t) => t.ref.startsWith(root + "/") || t.ref === root ); if (hasTabInRoot) { autoOpenedHomepageRef.current.add(root); @@ -160,7 +201,7 @@ function App() { } autoOpenedHomepageRef.current.add(root); - tabsOpenInNew(file.path); + tabsOpenInNew(fileTarget(file.path)); }, [tabsHydrated, library.activeRoot, library.activeScan, tabsList, tabsOpenInNew]); const rawFiles = library.activeScan?.result.files ?? []; @@ -260,7 +301,24 @@ function App() { if (typeof id === "number") clearTimeout(id); }; }, [quickOpenMounted]); - const filteredFiles = useFilteredFiles(allFiles, search); + const filteredFiles = useFilteredFiles(allFiles, search.query); + const activeRoots = useMemo( + () => (library.activeRoot ? [library.activeRoot] : []), + [library.activeRoot] + ); + // The tasks lens filters its own board, so it needs no content search. + const contentSearchEnabled = + search.open && viewSettings.settings.sidebarLens !== "tasks"; + const contentSearch = useContentSearch( + activeRoots, + search.query, + contentSearchEnabled, + search.scope + ); + const searchEntries = useMemo( + () => mergeSearchEntries(filteredFiles, contentSearch.hits, search.scope), + [filteredFiles, contentSearch.hits, search.scope] + ); const tree = useMemo(() => { if (!library.activeRoot) return undefined; return buildTree(library.activeRoot, filteredFiles); @@ -270,10 +328,20 @@ function App() { if (!tree) return; sidebar.collapseAll(collectDirKeys(tree, rootKey)); }, [tree, rootKey, sidebar]); - const activeFile = tabs.activeTab - ? allFiles.find((f) => f.path === tabs.activeTab?.path) + // Only a tab backed by a file has a path to place in the breadcrumb, an + // outline to draw, or a row to highlight in the sidebar. + const activeTab = tabs.activeTab; + const hidesExplorer = activeTab ? !TAB_KIND_SPECS[activeTab.kind].wantsExplorer : false; + useEffect(() => { + if (!hidesExplorer) setExplorerShownBeside(false); + }, [hidesExplorer]); + const activeFilePath = + activeTab && TAB_KIND_SPECS[activeTab.kind].readsFromDisk ? activeTab.ref : undefined; + const activeFile = activeFilePath + ? allFiles.find((f) => f.path === activeFilePath) : undefined; - const headerRelPath = activeFile?.relPath ?? (tabs.activeTab && basename(tabs.activeTab.path)); + const headerRelPath = + activeFile?.relPath ?? (activeFilePath ? basename(activeFilePath) : activeTab?.title); // Resolve the effective scheme so the toggle reflects what is actually // rendered, including when colorScheme is "system". const [systemDark, setSystemDark] = useState( @@ -333,6 +401,13 @@ function App() { [viewSettings] ); + const handleTaskViewChange = useCallback( + (taskTabView: TaskTabView) => { + viewSettings.update({ ...viewSettings.settings, taskTabView }); + }, + [viewSettings] + ); + const activeGitStatus = library.activeScan?.gitStatus; const gitStatusByPath = useMemo(() => { if (!activeGitStatus) return undefined; @@ -410,155 +485,80 @@ function App() { return ( -
- - {headerRelPath && ( - - )} - -
- -
- -
- {library.activeRoot && ( - - )} - {library.activeRoot && viewSettings.settings.sidebarLens === "tree" && ( - - )} - v && panes.setSplit(v as SplitMode)} - variant="outline" - spacing={0} - aria-label="Split layout" - className="mx-1" - > - - - - - - - - - - - {tabs.activeTab && ( - - )} - - - {settingsMounted && ( - - void handleOpenWelcome()} - updater={updater} - /> - - )} -
-
- + canCollapseAll={ + !!library.activeRoot && viewSettings.settings.sidebarLens === "tree" + } + onCollapseAll={handleCollapseAll} + split={panes.layout.split} + onSplitChange={panes.setSplit} + taskView={ + activeTab?.kind === TASKS_TARGET.kind + ? viewSettings.settings.taskTabView + : undefined + } + onTaskViewChange={handleTaskViewChange} + canToggleOutline={!!activeFilePath} + outlineOpen={viewSettings.settings.outlineOpen} + onToggleOutline={toggleOutline} + isDark={isDark} + onToggleTheme={() => + viewSettings.update({ + ...viewSettings.settings, + colorScheme: isDark ? "light" : "dark", + }) + } + onOpenSettings={() => { + setSettingsMounted(true); + setSettingsSection(undefined); + setSettingsOpen(true); + }} + onPrefetchSettings={() => setSettingsMounted(true)} + /> + {settingsMounted && ( + + void handleOpenWelcome()} + updater={updater} + /> + + )} void library.selectRoot(path)} - onRemoveRoot={(path) => void library.removeRoot(path)} onPickDirectory={() => void library.pickDirectory()} + onRefresh={() => library.activeRoot && void library.rescan(library.activeRoot)} onOpenWelcome={ viewSettings.settings.welcomeOpened ? undefined @@ -571,7 +571,10 @@ function App() { lens={viewSettings.settings.sidebarLens} onLensChange={handleLensChange} search={search} - onSearchChange={setSearch} + searchEntries={searchEntries} + searchingContents={contentSearch.searching} + searchError={contentSearch.error} + searchTruncated={contentSearch.truncated} filteredFiles={filteredFiles} pinnedFiles={pinnedFiles} tree={tree} @@ -589,13 +592,21 @@ function App() { setSettingsSection("explorer"); setSettingsOpen(true); }} - selectedPath={tabs.activeTab?.path} - onSelectFile={tabs.openInActive} - onOpenInNewTab={tabs.openInNew} - onOpenInOtherPane={panes.openInOtherPane} + selectedPath={activeFilePath} + onSelectFile={openFile} + onOpenInNewTab={openFileInNewTab} + onOpenInOtherPane={openFileInOtherPane} + onOpenTasksTab={openTasksTab} /> - + {/* The inset variant sets the card's gap with a hardcoded `m-2`, and its + flow position comes from a spacer the sidebar reserves at the same + hardcoded size; both are restated from the shared token, the second + as a pull-back so the card stays flush against the sidebar in either + state. Radius and drop shadow are restated at the same modifiers, + which is what lets tailwind-merge drop them rather than leaving two + rules to race: the seam is one hairline, not a line plus a halo. */} + 0} /> ) : ( { const v = layout["pane0"]; if (typeof v === "number") panes.setSplitSize(v); @@ -658,6 +670,7 @@ function App() { autoReloadOnExternalChange: true, }) } + onOpenInOtherPane={openFileInOtherPane} hasRoots={library.roots.length > 0} /> @@ -681,22 +694,23 @@ function App() { autoReloadOnExternalChange: true, }) } + onOpenInOtherPane={openFileInOtherPane} hasRoots={library.roots.length > 0} /> )} - {viewSettings.settings.outlineOpen && tabs.activeTab && !tabs.activeTab.loading && ( + {viewSettings.settings.outlineOpen && activeTab && activeFilePath && !activeTab.loading && ( )} @@ -708,7 +722,8 @@ function App() { open={quickOpen} onOpenChange={setQuickOpen} files={quickOpenFiles} - onSelect={(path) => tabs.openInActive(path)} + roots={library.roots} + onSelect={openInActive} /> )} diff --git a/src/components/document/DiffViewerDialog.tsx b/src/components/document/DiffViewerDialog.tsx index e9488b7..86c1aa4 100644 --- a/src/components/document/DiffViewerDialog.tsx +++ b/src/components/document/DiffViewerDialog.tsx @@ -165,16 +165,14 @@ function LineRow({ line, prefixed = false }: { line: DiffLine; prefixed?: boolea const cls = cn( "px-4 py-px", line.kind === "add" && - "bg-emerald-100/60 text-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200", - line.kind === "remove" && - "bg-rose-100/60 text-rose-900 dark:bg-rose-950/40 dark:text-rose-200" + "bg-[var(--status-success-bg)] text-[var(--status-success-fg)]", + line.kind === "remove" && "bg-[var(--status-error-bg)] text-[var(--status-error-fg)]" ); const segCls = (s: WordSegment): string => { if (!s.changed) return ""; if (line.kind === "add") - return "rounded-sm bg-emerald-300/50 dark:bg-emerald-700/50"; - if (line.kind === "remove") - return "rounded-sm bg-rose-300/50 dark:bg-rose-700/50"; + return "rounded-sm bg-[var(--status-success-fg)]/30"; + if (line.kind === "remove") return "rounded-sm bg-[var(--status-error-fg)]/30"; return ""; }; return ( diff --git a/src/components/document/DocumentView.theme.test.tsx b/src/components/document/DocumentView.theme.test.tsx new file mode 100644 index 0000000..98fb50c --- /dev/null +++ b/src/components/document/DocumentView.theme.test.tsx @@ -0,0 +1,141 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import { StrictMode } from "react"; +import { vi, describe, it, expect } from "vitest"; + +// jsdom has no matchMedia, and the "system" scheme is exactly the path that +// re-applies the theme from outside a React render. +class SchemeQuery extends EventTarget { + matches = false; +} +const schemeQuery = new SchemeQuery(); +vi.stubGlobal("matchMedia", () => schemeQuery); + +const crepeInstances: MockCrepe[] = []; +let destroyCount = 0; + +class MockCrepe { + static Feature = { AI: "ai" }; + markdown: string; + constructor({ defaultValue }: { defaultValue?: string }) { + this.markdown = defaultValue ?? ""; + crepeInstances.push(this); + } + create() { + return Promise.resolve(); + } + destroy() { + destroyCount += 1; + return Promise.resolve(); + } + getMarkdown() { + return this.markdown; + } +} + +vi.mock("@milkdown/crepe", () => ({ Crepe: MockCrepe })); + +import { + defaultViewSettings, + type AccentColor, + type ColorScheme, +} from "@/lib/storage"; +import type { Tab } from "@/hooks/useTabs"; +import { useTheme } from "@/hooks/useTheme"; +import { DocumentView } from "./DocumentView"; + +const DRAFT = "# hello"; +const UNSAVED = "# hello, an unsaved edit"; + +const tab: Tab = { + id: "t1", + kind: "file", + ref: "/w/notes/a.md", + title: "a.md", + content: DRAFT, + meta: {}, + error: undefined, + loading: false, + draft: DRAFT, +}; + +interface HarnessProps { + colorScheme: ColorScheme; + accentColor: AccentColor; +} + +function Harness({ colorScheme, accentColor }: HarnessProps) { + useTheme(colorScheme, accentColor); + return ( + + ); +} + +describe("the editor survives a theme change", () => { + it("keeps its instance and unsaved draft across both directions, system, and accent", async () => { + const { rerender } = render( + + + + ); + + await waitFor(() => expect(crepeInstances.length).toBeGreaterThan(0)); + const editor = crepeInstances[crepeInstances.length - 1]; + const host = screen.getByLabelText("Edit document"); + editor.markdown = UNSAVED; + // StrictMode's throwaway first mount already destroyed one instance. + const destroysAfterMount = destroyCount; + + const expectEditorIntact = () => { + expect(screen.getByLabelText("Edit document")).toBe(host); + expect(crepeInstances[crepeInstances.length - 1]).toBe(editor); + expect(destroyCount).toBe(destroysAfterMount); + expect(editor.getMarkdown()).toBe(UNSAVED); + expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument(); + }; + + rerender( + + + + ); + expect(document.documentElement.classList.contains("dark")).toBe(true); + expectEditorIntact(); + + rerender( + + + + ); + expect(document.documentElement.classList.contains("dark")).toBe(false); + expectEditorIntact(); + + rerender( + + + + ); + act(() => { + schemeQuery.matches = true; + schemeQuery.dispatchEvent(new Event("change")); + }); + expect(document.documentElement.classList.contains("dark")).toBe(true); + expectEditorIntact(); + + rerender( + + + + ); + expectEditorIntact(); + }); +}); diff --git a/src/components/document/DocumentView.tsx b/src/components/document/DocumentView.tsx index 2bf9291..0772981 100644 --- a/src/components/document/DocumentView.tsx +++ b/src/components/document/DocumentView.tsx @@ -85,7 +85,7 @@ export function DocumentView({ @@ -151,7 +151,7 @@ export function DocumentView({ fontSize={viewSettings.fontSize} codeThemeLight={viewSettings.codeThemeLight} codeThemeDark={viewSettings.codeThemeDark} - currentFilePath={tab.path} + currentFilePath={tab.ref} rootPath={rootPath} onNavigate={onNavigate} onToggleTask={onToggleTask} diff --git a/src/components/document/ExternalChangeBanner.tsx b/src/components/document/ExternalChangeBanner.tsx index 4e1f382..d07a356 100644 --- a/src/components/document/ExternalChangeBanner.tsx +++ b/src/components/document/ExternalChangeBanner.tsx @@ -29,14 +29,14 @@ export function ExternalChangeBanner({ return ( <> -
+
- +
- + File changed on disk - + {formatStats(stats)}
diff --git a/src/components/document/FileTabContent.tsx b/src/components/document/FileTabContent.tsx new file mode 100644 index 0000000..d149574 --- /dev/null +++ b/src/components/document/FileTabContent.tsx @@ -0,0 +1,46 @@ +import { useCallback } from "react"; +import { fileTarget } from "@/lib/tabKinds"; +import type { TabContentProps } from "./tabKinds"; +import { TabScrollPane } from "./TabScrollPane"; + +export function FileTabContent({ + tab, + pane, + active, + files, + rootPath, + viewSettings, + paneFocused, + onActiveScrollElChange, + onDiffViewModeChange, + onAlwaysAutoReload, +}: TabContentProps) { + const openInActive = pane.openInActive; + const navigate = useCallback( + (path: string) => openInActive(fileTarget(path)), + [openInActive] + ); + + return ( + f.path === tab.ref)} + active={active} + rootPath={rootPath} + viewSettings={viewSettings} + initialScrollTop={pane.getScrollTop(tab.ref)} + onScrollChange={pane.setScrollTop} + onNavigate={navigate} + onActiveRefChange={onActiveScrollElChange} + paneFocused={paneFocused} + onAcceptPending={pane.acceptPending} + onDismissPending={pane.dismissPending} + onDiffViewModeChange={onDiffViewModeChange} + onAlwaysAutoReload={onAlwaysAutoReload} + onBeginEdit={pane.beginEdit} + onCancelEdit={pane.cancelEdit} + onSaveEdit={pane.saveEdit} + onToggleTask={pane.toggleTaskItem} + /> + ); +} diff --git a/src/components/document/FindBar.test.tsx b/src/components/document/FindBar.test.tsx new file mode 100644 index 0000000..113ed73 --- /dev/null +++ b/src/components/document/FindBar.test.tsx @@ -0,0 +1,113 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi, describe, it, expect, beforeEach } from "vitest"; + +import { FindBar } from "./FindBar"; +import type { FindInDocument } from "@/hooks/useFindInDocument"; + +const actions = { + setQuery: vi.fn(), + next: vi.fn(), + previous: vi.fn(), + show: vi.fn(), + hide: vi.fn(), +}; + +function find(overrides: Partial = {}): FindInDocument { + return { + open: true, + query: "needle", + matchCount: 17, + currentIndex: 2, + ...actions, + ...overrides, + }; +} + +describe("FindBar", () => { + beforeEach(() => vi.clearAllMocks()); + + it("shows the position within the matches", () => { + render(); + + expect(screen.getByText("3 of 17")).toBeInTheDocument(); + }); + + it("says when nothing matched", () => { + render(); + + expect(screen.getByText("No results")).toBeInTheDocument(); + }); + + it("shows no count before anything is typed", () => { + render(); + + expect(screen.queryByText("No results")).not.toBeInTheDocument(); + }); + + it("moves to the next match", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByLabelText("Next match")); + + expect(actions.next).toHaveBeenCalled(); + }); + + it("moves to the previous match", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByLabelText("Previous match")); + + expect(actions.previous).toHaveBeenCalled(); + }); + + it("disables navigation when there is nothing to step through", () => { + render(); + + expect(screen.getByLabelText("Next match")).toBeDisabled(); + expect(screen.getByLabelText("Previous match")).toBeDisabled(); + }); + + it("advances on Enter and steps back on Shift+Enter", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Find in document"); + + await user.click(input); + await user.keyboard("{Enter}"); + expect(actions.next).toHaveBeenCalledTimes(1); + + await user.keyboard("{Shift>}{Enter}{/Shift}"); + expect(actions.previous).toHaveBeenCalledTimes(1); + }); + + it("closes on Escape", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByLabelText("Find in document")); + await user.keyboard("{Escape}"); + + expect(actions.hide).toHaveBeenCalled(); + }); + + it("closes from the close button", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByLabelText("Close find")); + + expect(actions.hide).toHaveBeenCalled(); + }); + + it("reports typing to the caller", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Find in document"), "a"); + + expect(actions.setQuery).toHaveBeenCalledWith("a"); + }); +}); diff --git a/src/components/document/FindBar.tsx b/src/components/document/FindBar.tsx new file mode 100644 index 0000000..33ffc87 --- /dev/null +++ b/src/components/document/FindBar.tsx @@ -0,0 +1,89 @@ +import { useEffect, useRef } from "react"; +import { ChevronDown, ChevronUp, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { FindInDocument } from "@/hooks/useFindInDocument"; + +interface Props { + find: FindInDocument; +} + +export function FindBar({ find }: Props) { + const input = useRef(null); + + useEffect(() => { + input.current?.select(); + }, []); + + const hasQuery = find.query.trim().length > 0; + + return ( +
+ find.setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (e.shiftKey) find.previous(); + else find.next(); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + find.hide(); + } + }} + placeholder="Find in document" + aria-label="Find in document" + className="h-7 w-48 border-0 shadow-none focus-visible:ring-0" + /> + + {matchLabel(hasQuery, find.matchCount, find.currentIndex)} + + + + +
+ ); +} + +function matchLabel(hasQuery: boolean, matchCount: number, currentIndex: number): string { + if (!hasQuery) return ""; + if (matchCount === 0) return "No results"; + return `${currentIndex + 1} of ${matchCount}`; +} diff --git a/src/components/document/PaneView.tsx b/src/components/document/PaneView.tsx index 7f3b93f..958c99c 100644 --- a/src/components/document/PaneView.tsx +++ b/src/components/document/PaneView.tsx @@ -5,7 +5,7 @@ import type { ViewSettings } from "@/lib/storage"; import type { Tabs } from "@/hooks/useTabs"; import { EmptyDocument } from "./EmptyDocument"; import { TabBar } from "./TabBar"; -import { TabScrollPane } from "./TabScrollPane"; +import { TAB_KIND_VIEWS } from "./tabKinds"; interface Props { pane: Tabs; @@ -21,6 +21,7 @@ interface Props { onActiveScrollElChange: (el: HTMLElement | null) => void; onDiffViewModeChange: (mode: ViewSettings["diffViewMode"]) => void; onAlwaysAutoReload: () => void; + onOpenInOtherPane?: (path: string) => void; // hasRoots controls the empty-state message inside EmptyDocument. hasRoots: boolean; } @@ -36,6 +37,7 @@ export function PaneView({ onActiveScrollElChange, onDiffViewModeChange, onAlwaysAutoReload, + onOpenInOtherPane, hasRoots, }: Props) { // Capture mousedown so that clicking inside an unfocused pane focuses @@ -67,28 +69,21 @@ export function PaneView({
) : ( pane.tabs.map((tab) => { - const file = files.find((f) => f.path === tab.path); - const active = tab.id === pane.activeId; + const Content = TAB_KIND_VIEWS[tab.kind].content; return ( - ); }) diff --git a/src/components/document/TabBar.tsx b/src/components/document/TabBar.tsx index 3bd0122..787c350 100644 --- a/src/components/document/TabBar.tsx +++ b/src/components/document/TabBar.tsx @@ -11,8 +11,11 @@ interface Props { export function TabBar({ tabs, activeId, onActivate, onClose }: Props) { if (tabs.length === 0) return null; + // The strip scrolls sideways only. Left alone, setting one axis makes the + // browser compute the other to auto as well, and a stray pixel of height + // paints a vertical scrollbar over the tab titles. return ( -
+
{tabs.map((tab) => ( {tab.title} {tab.pendingContent && ( diff --git a/src/components/document/TabScrollPane.scroll.test.tsx b/src/components/document/TabScrollPane.scroll.test.tsx new file mode 100644 index 0000000..3b52e03 --- /dev/null +++ b/src/components/document/TabScrollPane.scroll.test.tsx @@ -0,0 +1,201 @@ +import { act, render } from "@testing-library/react"; +import { StrictMode } from "react"; +import { vi, describe, it, expect, beforeEach } from "vitest"; + +// jsdom has no matchMedia, and "system" is the scheme that re-applies the theme +// from outside a React render. +class SchemeQuery extends EventTarget { + matches = false; +} +const schemeQuery = new SchemeQuery(); +vi.stubGlobal("matchMedia", () => schemeQuery); + +// jsdom lays nothing out, so the pane would never look scrollable and the +// restore path would never run. +const DOCUMENT_HEIGHT = 5000; +const VIEWPORT = 800; +Object.defineProperty(HTMLElement.prototype, "scrollHeight", { + configurable: true, + get: () => DOCUMENT_HEIGHT, +}); +Object.defineProperty(HTMLElement.prototype, "clientHeight", { + configurable: true, + get: () => VIEWPORT, +}); + +import { defaultViewSettings, type ColorScheme } from "@/lib/storage"; +import type { Tab } from "@/hooks/useTabs"; +import { useTheme } from "@/hooks/useTheme"; +import { TabScrollPane } from "./TabScrollPane"; + +const REF = "/w/notes/a.md"; + +const loadedTab: Tab = { + id: "t1", + kind: "file", + ref: REF, + title: "a.md", + content: "# hello\n\nbody", + meta: {}, + error: undefined, + loading: false, +}; +const loadingTab: Tab = { ...loadedTab, content: "", loading: true }; + +const memory = new Map(); + +interface HarnessProps { + colorScheme: ColorScheme; + tab: Tab; + active?: boolean; +} + +function Harness({ colorScheme, tab, active = true }: HarnessProps) { + useTheme(colorScheme, "violet"); + return ( + memory.set(ref, value)} + onNavigate={vi.fn()} + paneFocused + onAcceptPending={vi.fn()} + onDismissPending={vi.fn()} + onDiffViewModeChange={vi.fn()} + onAlwaysAutoReload={vi.fn()} + onBeginEdit={vi.fn()} + onCancelEdit={vi.fn()} + onSaveEdit={vi.fn()} + onToggleTask={vi.fn()} + /> + ); +} + +function paneOf(container: HTMLElement): HTMLElement { + return container.firstElementChild as HTMLElement; +} + +function scrollTo(el: HTMLElement, top: number): void { + act(() => { + el.scrollTop = top; + el.dispatchEvent(new Event("scroll", { bubbles: true })); + }); +} + +describe("the reader's position in a document", () => { + beforeEach(() => { + memory.clear(); + schemeQuery.matches = false; + }); + + // A pane that mounts with its document already in hand is the split being + // toggled: App swaps the single PaneView for the resizable group, so every + // TabScrollPane under it is built afresh around content it already has. + it("is remembered when the pane mounts with the document already loaded", () => { + const { container } = render( + + + + ); + + scrollTo(paneOf(container), 1200); + + expect(memory.get(REF)).toBe(1200); + }); + + it("is remembered when the pane mounts while the document is still loading", () => { + const { container, rerender } = render( + + + + ); + rerender( + + + + ); + + scrollTo(paneOf(container), 1200); + + expect(memory.get(REF)).toBe(1200); + }); + + it("survives a colour scheme change, in both directions and through system", () => { + const { container, rerender } = render( + + + + ); + rerender( + + + + ); + const el = paneOf(container); + scrollTo(el, 1200); + + const expectHeld = () => { + expect(el.scrollTop).toBe(1200); + expect(memory.get(REF)).toBe(1200); + }; + + rerender( + + + + ); + expect(document.documentElement.classList.contains("dark")).toBe(true); + expectHeld(); + + rerender( + + + + ); + expect(document.documentElement.classList.contains("dark")).toBe(false); + expectHeld(); + + rerender( + + + + ); + act(() => { + schemeQuery.matches = true; + schemeQuery.dispatchEvent(new Event("change")); + }); + expect(document.documentElement.classList.contains("dark")).toBe(true); + expectHeld(); + }); + + it("is restored on mount and survives a tab switch", () => { + memory.set(REF, 1200); + const { container, rerender } = render( + + + + ); + const el = paneOf(container); + expect(el.scrollTop).toBe(1200); + + scrollTo(el, 2400); + + rerender( + + + + ); + rerender( + + + + ); + + expect(memory.get(REF)).toBe(2400); + expect(el.scrollTop).toBe(2400); + }); +}); diff --git a/src/components/document/TabScrollPane.tsx b/src/components/document/TabScrollPane.tsx index b9a38ad..99a60bd 100644 --- a/src/components/document/TabScrollPane.tsx +++ b/src/components/document/TabScrollPane.tsx @@ -1,11 +1,16 @@ -import { useEffect, useLayoutEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import type { MarkdownFile } from "@/lib/scan"; import type { ViewSettings } from "@/lib/storage"; import type { Tab } from "@/hooks/useTabs"; import { parseFrontmatter } from "@/lib/scan"; +import { useFindInDocument } from "@/hooks/useFindInDocument"; +import { FIND_CHROME_ATTR } from "@/lib/findMatches"; +import { matchShortcut, parseShortcut } from "@/lib/shortcuts"; import { DocumentView } from "./DocumentView"; import { ExternalChangeBanner } from "./ExternalChangeBanner"; +import { FindBar } from "./FindBar"; + interface Props { tab: Tab; @@ -14,9 +19,11 @@ interface Props { rootPath: string | undefined; viewSettings: ViewSettings; initialScrollTop: number; - onScrollChange: (path: string, value: number) => void; + onScrollChange: (ref: string, value: number) => void; onNavigate: (path: string) => void; onActiveRefChange?: (el: HTMLElement | null) => void; + /** False when a split is showing and the other pane holds focus. */ + paneFocused: boolean; onAcceptPending: (id: string) => void; onDismissPending: (id: string) => void; onDiffViewModeChange: (mode: ViewSettings["diffViewMode"]) => void; @@ -37,6 +44,7 @@ export function TabScrollPane({ onScrollChange, onNavigate, onActiveRefChange, + paneFocused, onAcceptPending, onDismissPending, onDiffViewModeChange, @@ -51,19 +59,48 @@ export function TabScrollPane({ [tab.pendingContent] ); const ref = useRef(null); - const restoredRef = useRef(false); + // Holds the document the pane has already placed. Keyed by path rather than + // flagged, because a flag has to be cleared by an effect, and an effect + // clearing it always runs after the layout effect that set it. + const restoredFor = useRef(undefined); + const [scrollEl, setScrollEl] = useState(null); + + useEffect(() => setScrollEl(ref.current), []); + + // Find applies to the rendered view only; the editor brings its own. + const findShortcut = useMemo( + () => parseShortcut(viewSettings.findInDocumentShortcut), + [viewSettings.findInDocumentShortcut] + ); + // The markdown body memoises on its component map, which is rebuilt whenever + // this handler changes identity. An inline arrow here re-parsed every open + // document on every tab switch. + const toggleTask = useCallback( + (index: number) => void onToggleTask(tab.id, index), + [onToggleTask, tab.id] + ); + const findable = active && paneFocused && tab.draft === undefined; + const find = useFindInDocument(findable ? scrollEl : null, findable); + const showFind = find.show; useEffect(() => { - restoredRef.current = false; - }, [tab.path]); + if (!findable || !findShortcut) return; + const onKey = (e: KeyboardEvent) => { + if (!matchShortcut(e, findShortcut)) return; + e.preventDefault(); + showFind(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [findable, findShortcut, showFind]); useLayoutEffect(() => { const el = ref.current; if (!el) return; - if (restoredRef.current) return; + if (restoredFor.current === tab.ref) return; if (tab.loading || tab.error) return; if (initialScrollTop === 0) { - restoredRef.current = true; + restoredFor.current = tab.ref; return; } @@ -75,32 +112,32 @@ export function TabScrollPane({ }; if (tryRestore()) { - restoredRef.current = true; + restoredFor.current = tab.ref; return; } const inner = el.firstElementChild; if (!inner) return; const observer = new ResizeObserver(() => { - if (restoredRef.current) { + if (restoredFor.current === tab.ref) { observer.disconnect(); return; } if (tryRestore()) { - restoredRef.current = true; + restoredFor.current = tab.ref; observer.disconnect(); } }); observer.observe(inner); const safety = window.setTimeout(() => { - restoredRef.current = true; + restoredFor.current = tab.ref; observer.disconnect(); }, 4000); return () => { observer.disconnect(); clearTimeout(safety); }; - }, [tab.loading, tab.error, tab.content, initialScrollTop]); + }, [tab.ref, tab.loading, tab.error, tab.content, initialScrollTop]); useEffect(() => { if (!onActiveRefChange) return; @@ -116,10 +153,22 @@ export function TabScrollPane({ className={cn("absolute inset-0 overflow-y-auto", !active && "invisible")} aria-hidden={!active} onScroll={(e) => { - if (!restoredRef.current) return; - onScrollChange(tab.path, e.currentTarget.scrollTop); + if (restoredFor.current !== tab.ref) return; + onScrollChange(tab.ref, e.currentTarget.scrollTop); }} > + {/* Sticky with no height so the bar stays pinned while the document + scrolls beneath it without displacing the content. */} + {find.open && ( +
+
+ +
+
+ )} {tab.pendingContent && pendingBody !== undefined && ( void onBeginEdit(tab.id)} onCancelEdit={() => onCancelEdit(tab.id)} onSaveEdit={(markdown) => onSaveEdit(tab.id, markdown)} - onToggleTask={(index) => void onToggleTask(tab.id, index)} + onToggleTask={toggleTask} />
); diff --git a/src/components/document/crepe-theme.css b/src/components/document/crepe-theme.css index 96ecf8b..d1d1b03 100644 --- a/src/components/document/crepe-theme.css +++ b/src/components/document/crepe-theme.css @@ -1,12 +1,53 @@ /* Aligns Crepe's frame theme with the DocsReader reader: swap in the - app's Geist fonts, and drive dark mode from the app's own `.dark` - toggle rather than the theme's separate dark stylesheet. The dark - palette below is frame-dark's own, applied only under `.dark`. */ + app's Geist fonts and re-point Crepe's palette at the app's theme + tokens, which already flip under the app's own `.dark` toggle, so the + editor follows the scheme and the accent instead of the two fixed + palettes the frame theme ships. */ .milkdown { --crepe-font-default: var(--font-sans, "Geist Variable", sans-serif); --crepe-font-title: var(--font-sans, "Geist Variable", sans-serif); --crepe-font-code: var(--font-mono, "Geist Mono Variable", ui-monospace, monospace); + + --crepe-color-background: var(--background); + --crepe-color-on-background: var(--foreground); + --crepe-color-surface: var(--popover); + --crepe-color-surface-low: var(--muted); + --crepe-color-on-surface: var(--popover-foreground); + --crepe-color-on-surface-variant: var(--muted-foreground); + /* Crepe uses "outline" for icon fills and placeholder text, not only for + borders, so it maps to the muted foreground rather than to --border. + Every caret is pulled off it below: muted is right for a placeholder and + wrong for the insertion point, which has to out-read the text around it. */ + --crepe-color-outline: var(--muted-foreground); + --crepe-color-caret: var(--foreground); + --crepe-color-primary: var(--primary); + --crepe-color-secondary: var(--secondary); + --crepe-color-on-secondary: var(--secondary-foreground); + --crepe-color-inverse: var(--foreground); + --crepe-color-on-inverse: var(--background); + /* The reader draws inline code as a neutral chip (bg-muted / text-foreground); + Crepe's palette makes it crimson. Same document, so same chip. */ + --crepe-color-inline-code: var(--foreground); + --crepe-color-error: var(--destructive); + --crepe-color-hover: var(--accent); + --crepe-color-selected: color-mix(in oklch, var(--primary) 25%, transparent); + --crepe-color-inline-area: var(--muted); +} + +/* The prose caret is drawn by prosemirror-virtual-cursor, not by caret-color: + Crepe's cursor.css points --prosemirror-virtual-cursor-color at + --crepe-color-outline, and the auxiliary inputs (code-block search panel, + image caption) set caret-color from the same variable. Those four rules are + every caret in the editor; each is re-pointed at --crepe-color-caret. */ +.crepe-host .milkdown .ProseMirror-focused { + --prosemirror-virtual-cursor-color: var(--crepe-color-caret); +} +.crepe-host .milkdown .milkdown-code-block .cm-panel input, +.crepe-host .milkdown .milkdown-code-block .tools input, +.crepe-host .milkdown .milkdown-image-inline input, +.crepe-host .milkdown .milkdown-image-block input { + caret-color: var(--crepe-color-caret); } /* Read like the document, not a boxed input. Crepe's frame theme paints @@ -64,6 +105,8 @@ } .crepe-host .milkdown .ProseMirror :not(pre) > code { font-size: 0.875em; + /* Crepe fades the chip to 60%; the reader's bg-muted is solid. */ + background: var(--muted); } /* Crepe renders bullets, numbers, and checkboxes all inside .label-wrapper. @@ -89,23 +132,3 @@ .crepe-host .milkdown [data-type="html"][data-value^="