Skip to content

feat(desktop): editing, the sources screen, the checks and the history (3.5, 6.2-6.7, 7.6, 8.6-8.9, 8.11) - #14

Merged
protonspy merged 2 commits into
mainfrom
feat/desktop-ui
Aug 1, 2026
Merged

feat(desktop): editing, the sources screen, the checks and the history (3.5, 6.2-6.7, 7.6, 8.6-8.9, 8.11)#14
protonspy merged 2 commits into
mainfrom
feat/desktop-ui

Conversation

@protonspy

@protonspy protonspy commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Closes 3.5, 6.2, 6.4, 6.5, 6.6, 6.7, 7.6, 8.6, 8.7, 8.8, 8.9 and 8.11 of plans/open-wiki.md.

6.3 is deliberately not closed — see below.

What changed

Editing goes through gateWrite, the same entrance the agent's writes use. Group 5's claim is one implementation with three callers, and an editor that validated its own way would be a fourth quietly disagreeing with the hook about what a well-formed page is. Denial reasons are shown verbatim, because 9.13 says the message has three mouths.

8.8 checks staleness before the gate — the two have different answers, "look at what changed" versus "fix this field" — and uses isStoreOnlyChange rather than a string comparison, because a correction the store made is not somebody else's edit (5.8). When it refuses it shows both versions and merges neither.

A rename repoints what pointed at the page and is one operation, so undo puts the whole thing back. A delete deliberately does not rewrite the links: they are the record that something was expected to be there, and 7.1 reports them. A rename knows where the reader should go instead; a delete does not.

The write path is a separate module from the read path. api.ts imports @open-wiki/access/read and cannot write whatever it is asked to; edit.ts imports the barrel and can. That makes "the read surface is read-only" a fact about the import graph.

What the reviews caught

The first pass shipped a PR where none of it worked: the preload.ts edit was lost in a rebase, so every new feature typechecked, tested green, and threw "not a function" on the first click. bridge.ts asserts the shape of window.ow rather than deriving it, so nothing noticed. There is a type-level parity check in the preload now.

Two security holes, both in the new write path:

  • rename could overwrite CLAUDE.md and .claude/rules/* — it built a path from a renderer-supplied name and was the one write that never called the gate, so 9.6's config guard was never consulted. ../CLAUDE typed into the rename prompt was enough. That is instruction injection into the agent that has tool access to the machine.
  • create's slug escaped wiki/ — and the gate has no opinion about anything outside the wiki, so the traversal also skipped every check.

undo is renderer-reachable now and reads .state/log.jsonl, which arrives with a clone. An unchecked snapshotId copied files from outside the project in; a page path of .git/config with existed: false was a delete.

And on Windows — the platform this product targets — renaming a page to a case variant deleted it.

Full list in the follow-up commit message.

How it was verified

  • pnpm test — 976 passing (169 in @open-wiki/desktop)
  • pnpm --filter @open-wiki/desktop test:coverage — above the 76% floor
  • pnpm run typecheck, pnpm lint, prettier --check — clean
  • scc validate — no findings
  • code-review and security-review subagents on the diff; every finding closed or recorded

6.3 is unticked

The per-chunk progress is on the sources screen, because sourceState derives it from the journal for free. There is no transcribe button and no retry-what-failed: starting a run needs the Groq credential of 8.3, and 4.15 deliberately left that wiring to the desktop application because the CLI must never read the key. It lands with 8.3.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D3VYWWTZtEE2NxPiksKsAK

Summary by CodeRabbit

  • New Features
    • Added Markdown page editing with live preview, conflict handling, creation, renaming, deletion, and undo.
    • Added source browsing, citation navigation, integrity findings, and source retitling.
    • Added drag-and-drop ingestion for Markdown, text, PDF, and DOCX files.
    • Added operation history and one-click undo.
  • Bug Fixes
    • Improved validation and safety for page paths, links, citations, and undo operations.
  • Documentation
    • Updated the implementation plan to reflect completed features.

protonspy and others added 2 commits August 1, 2026 15:40
Plan 3.5, 6.2 to 6.7, 7.6, 8.6 to 8.9 and 8.11.

Editing goes through `gateWrite` — the same entrance the agent's writes use.
Group 5's whole claim is one implementation with three callers, and an editor
that validated its own way would be a fourth, quietly disagreeing with the
hook about what a well-formed page is. The store's denial reasons are shown
verbatim, because 9.13 says the message has three mouths and they have to say
the same thing.

8.8's refusal checks staleness *before* the gate: the two have different
answers, one being "look at what changed" and the other "fix this field". And
it is `isStoreOnlyChange` rather than a string comparison, because a
correction the store itself made is not somebody else's edit (5.8) — the
editor also takes back what *landed* rather than what it sent, or its next
save would look stale against a change it did not make. When it refuses, it
shows both versions and merges neither: at that moment both still exist, and
the only unrecoverable outcome is picking one silently.

A rename repoints what pointed at the page and is one operation, so undo puts
the rename and every repointed link back together; the page's `id` follows its
slug keeping its type, or 5.1 would refuse every later save of it. A delete
deliberately does *not* rewrite the links — they are the record that something
was expected to be there, and 7.1 reports them. A rename knows where the
reader should go instead; a delete does not.

The write path is a separate module from the read path on purpose. `api.ts`
imports `@open-wiki/access/read` and cannot write whatever it is asked to;
`edit.ts` imports the barrel and can. Keeping them apart makes "the read
surface is read-only" a fact about the import graph rather than a promise.

The sources screen derives every row from the directory, which 6.1 already
settled as the only record — one read of the wiki feeds all of them, so twenty
sources are not twenty walks over the pages. It flags a source nobody cites,
which is the case that disappears from view on its own. A title is correctable
without moving the directory or touching a citation, which is what makes
`adr:0011`'s freeze bearable.

8.6 seeks the audio to the instant rather than starting it at zero — that
difference is the entire task — and a citation the recording does not contain
says so instead, which is the same answer 5.4 gives when it refuses one. The
original of an uploaded source is found by deriving `source.<ext>` from the
id rather than reading a filename out of the manifest: the manifest is a file
in a project directory that arrives with a clone.

3.5 reports what was recognised *and* what was not, because a partial success
reported as success is how a source silently never arrives.

7.6 is rendering rather than new checking, exactly as the deferral said: every
finding already carries its `fix`. 8.11 is honest in the panel itself about
covering only what was observed — a page written through a harness with no
hooks is not in the log, and a history presenting itself as complete would
make "undo" silently mean "undo some of it".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3VYWWTZtEE2NxPiksKsAK
**None of it worked.** `preload.ts` never gained the twelve new channels — the
edit was lost in a rebase — so every new feature typechecked, tested green,
and threw "not a function" the first time anyone clicked. Nothing caught it
because `bridge.ts` *asserts* the shape of `window.ow` rather than deriving
it. The preload now carries them, and a type-level parity check at the bottom
of that file makes the same gap a compile error rather than a runtime one.

Two holes with teeth, both in the write path:

- **`rename` could overwrite `CLAUDE.md` and `.claude/rules/*`.** It built a
  path from a renderer-supplied name and was the one write in the module that
  never called `gateWrite` — so `isConfigWrite`, the guard 9.6 exists for,
  was never consulted. `../CLAUDE` was enough, from the rename prompt, by
  hand. That is instruction injection into the agent that has tool access to
  this machine.
- **`create`'s slug escaped `wiki/`.** `gateWrite` classifies by where a write
  lands and has no opinion about anything outside the wiki, so a traversal
  slug both escaped *and* skipped every check the gate exists to apply.

Both now validate the name against the store's own slug shape before a path is
built from it, and the rename goes through the gate like every other write —
which also catches what `renameId` exists to prevent, arriving through the
front door: a name that is not a slug produces an `id` the schema refuses, and
every later save of that page would fail forever.

`undo` is the other one. It is renderer-reachable now, and it reads
`.state/log.jsonl` — a file in the project directory, which arrives with a
clone. `op.snapshotId` was joined onto a path unchecked, so a crafted `../..`
made `assertWithin(snapDir, …)` confine against a root the attacker chose and
copied files from *outside* the project in; and a page path of `.git/config`
with `existed: false` was a delete. The id must now be a UUID and the paths
must be under `wiki/` or `raw/`.

Data loss, on the platform this product targets: renaming a page to a case
variant deleted it. Windows is case-insensitive, `realpathSync` canonicalises
`wiki/Fenix.md` back to `wiki\fenix.md`, so the write landed on the source and
the `rmSync` after it removed the page. "Rename to fix the capitalisation" is
an ordinary thing to do.

`renameId`'s regex crossed the closing `---`: on a page with no `id` in its
frontmatter it rewrote the first `id:` in the *prose* and swallowed the rest
of that line, and it was quadratic — a megabyte page froze the main process
for half a minute, and page content is what arrives with a clone. It now
splits the block off and works only inside it.

A rename left a broken link in `wiki/index.md` and undo of a create left a
dangling entry there, both because `listPages` deliberately excludes the
non-entity pages and `registerInIndex` writes one of them. The rename repoints
them too; the create's snapshot covers the index.

The 8.8 conflict button said "use the version on disk as my base" and armed
the user's own buffer to win — the silent loss with a reassuring label on it.
Two buttons now, each doing what it says.

And 3.5's drop never produced a path: `File.path` was removed in Electron 32
and this app pins 38, so the list was always empty and the handler returned
silently. The preload supplies it via `webUtils.getPathForFile`, and a drop
that yields nothing now says so.

6.3's box came back. The per-chunk progress is on the sources screen because
`sourceState` derives it for free, but there is no transcribe button and no
retry-what-failed — it needs 8.3's credential, and a ticked box is the only
record the next session gets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3VYWWTZtEE2NxPiksKsAK
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The desktop application adds page editing and lifecycle operations, source inspection, citation navigation, integrity findings, file-drop ingestion, operation history, undo, and typed IPC and renderer support.

Changes

Desktop editing and undo

Layer / File(s) Summary
Page editing and undo safety
apps/desktop/src/main/edit.ts, packages/access/src/write/undo.ts, packages/access/src/index.ts, apps/desktop/tests/edit.spec.ts, plans/open-wiki.md
Adds gated save and create operations, safe rename and delete flows, wikilink updates, source retitling, operation history, atomic snapshots, protected undo, and integration tests.

Source ingestion and citation

Layer / File(s) Summary
Source ingestion and citation services
apps/desktop/src/main/ingest.ts, apps/desktop/src/main/sources.ts, apps/desktop/tests/sources.spec.ts, apps/desktop/package.json, packages/audio/src/index.ts, plans/open-wiki.md
Adds source listing, findings, citation location resolution, preserved-file lookup, Markdown/text/PDF/DOCX ingestion, duplicate handling, and related tests and package exports.

IPC and renderer

Layer / File(s) Summary
IPC and preload contracts
apps/desktop/src/main/ipc.ts, apps/desktop/src/main/preload.ts, apps/desktop/src/renderer/bridge.ts, apps/desktop/tests/sources.spec.ts
Adds typed channels and bridge methods for editing, sources, findings, history, undo, citation lookup, and file drops. Preload input filtering and API parity checks are included.
Renderer editing and source views
apps/desktop/src/renderer/App.tsx, apps/desktop/src/renderer/Editor.tsx, apps/desktop/src/renderer/Panels.tsx, apps/desktop/src/renderer/Sources.tsx, apps/desktop/src/renderer/navigation.ts, apps/desktop/src/renderer/index.html, apps/desktop/src/renderer/tokens.css
Adds editor conflict handling, page CRUD controls, source and findings screens, history and citation panels, drag-and-drop feedback, reload propagation, media loading, navigation, and styling.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Renderer
  participant Preload
  participant MainIPC
  participant EditModule
  participant FileSystem
  User->>Renderer: Edit and save Markdown
  Renderer->>Preload: Send slug, content, and base content
  Preload->>MainIPC: Invoke save channel
  MainIPC->>EditModule: Call savePage
  EditModule->>FileSystem: Validate, snapshot, and write page
  FileSystem-->>EditModule: Return persisted content and operation
  EditModule-->>MainIPC: Return save result
  MainIPC-->>Preload: Return save result
  Preload-->>Renderer: Update editor or show conflict
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.05% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main desktop changes: editing, sources, checks, and history.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/desktop-ui

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

🧹 Nitpick comments (8)
apps/desktop/src/renderer/tokens.css (1)

405-410: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a single-column fallback for the editor panes.

.editor__panes is always two equal columns. Plan 8.2 describes a window read beside the harness, so a narrow width is a normal case, and at that width both the source and the preview become too narrow to use. The citation panel at line 473 already guards its width with min().

🎨 Proposed change
 .editor__panes {
   display: grid;
   grid-template-columns: 1fr 1fr;
   gap: var(--space-4);
   min-height: 0;
 }
+
+@media (max-width: 720px) {
+  .editor__panes {
+    grid-template-columns: 1fr;
+  }
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/tokens.css` around lines 405 - 410, Update the
.editor__panes grid to provide a single-column fallback at narrow widths, while
preserving the two-column layout when sufficient space is available. Use a
responsive grid definition or media query consistent with the existing citation
panel’s min() width handling.
packages/audio/src/index.ts (1)

10-10: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a dedicated @open-wiki/audio/atomic export.

Add the subpath to packages/audio/package.json and import writeAtomic from it. The barrel also loads ffmpeg.js, which imports node:child_process.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/audio/src/index.ts` at line 10, Add an "./atomic" subpath export in
packages/audio/package.json pointing to the atomic module, and update the
package entry point to import writeAtomic from that dedicated subpath instead of
re-exporting through the barrel. Keep the atomic export isolated so consumers do
not load ffmpeg.js or node:child_process.
apps/desktop/src/renderer/App.tsx (1)

335-341: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use a key that stays unique across a drop.

outcome.name is a basename. A drop of two files with the same name from different folders produces duplicate keys, which makes React warn and reconcile the wrong list item. Use the index together with the name.

♻️ Proposed change
-        {outcomes.map((outcome) => (
-          <li key={outcome.name}>
+        {outcomes.map((outcome, i) => (
+          <li key={`${outcome.name}-${i}`}>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/App.tsx` around lines 335 - 341, Update the
outcomes.map rendering in App.tsx to construct each list item's key from both
outcome.name and the map index, ensuring duplicate basenames from different
files remain unique while preserving the existing displayed content.
apps/desktop/src/renderer/Panels.tsx (1)

133-140: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

An older locate response can overwrite a newer one.

The effect has no cleanup. If id or fragment changes while a request is in flight, the earlier promise can resolve last and set a location for the previous citation. Ignore the result after the effect is cleaned up.

♻️ Proposed change
   useEffect(() => {
+    let live = true;
     void bridge()
       .locate(id, fragment)
-      .then(setAt)
-      .catch((e: unknown) =>
-        setAt({ kind: "missing", reason: e instanceof Error ? e.message : String(e) }),
-      );
+      .then((next) => {
+        if (live) setAt(next);
+      })
+      .catch((e: unknown) => {
+        if (live) setAt({ kind: "missing", reason: e instanceof Error ? e.message : String(e) });
+      });
+    return () => {
+      live = false;
+    };
   }, [id, fragment]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/Panels.tsx` around lines 133 - 140, Update the
useEffect containing bridge().locate so it tracks whether the current effect is
still active and ignores both successful and failed results after cleanup. Set
the location only while the request corresponds to the current id and fragment,
and return an effect cleanup that marks the request inactive.
apps/desktop/src/renderer/Editor.tsx (1)

104-112: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

State why the preview may use dangerouslySetInnerHTML.

App.tsx carries a comment that explains the rendered HTML comes from renderPageBody with html: false and a closed set of tags. The same reasoning applies here, and a static analysis rule flags this line. Add the same note so a later reader does not have to rediscover it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/Editor.tsx` around lines 104 - 112, Add a concise
explanatory comment immediately before the dangerouslySetInnerHTML usage in the
preview article, matching the rationale documented in App.tsx: preview HTML is
produced by renderPageBody with html: false and a closed set of allowed tags. Do
not change the rendering behavior.

Source: Linters/SAST tools

apps/desktop/tests/edit.spec.ts (2)

92-96: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the refusal reason before you read fields from it.

The if guard makes the assertion optional. If reason were "stale", the block is skipped and the test still passes. The same pattern appears at Lines 182-185.

♻️ Proposed change
     expect(result.saved).toBe(false);
-    if (result.saved === false && result.reason === "invalid") {
-      expect(result.problems.join(" ")).toContain("ghost");
-    }
+    expect(result.saved === false && result.reason).toBe("invalid");
+    if (result.saved === false && result.reason === "invalid") {
+      expect(result.problems.join(" ")).toContain("ghost");
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/tests/edit.spec.ts` around lines 92 - 96, Update the assertions
around the saved-result checks in the test cases near the existing guard and the
matching case near lines 182-185 to assert that result.reason is exactly
"invalid" before accessing result.problems. Remove the conditional guard so an
unexpected refusal reason fails the test rather than skipping the
problem-content assertion.

443-450: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Wall-clock budget can make this test flaky.

The 1000 ms budget depends on the machine and on CI load. The regression it guards is quadratic scanning, so a generous budget or a larger input keeps the signal without the flake risk. Consider raising the budget, or asserting the output instead of the duration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/tests/edit.spec.ts` around lines 443 - 450, Update the “is fast
on a long page” test around renameId to avoid a machine-dependent 1000 ms
wall-clock assertion: either use a substantially more generous threshold with
the existing large input or assert the renamed output while retaining a
sufficiently large input to catch quadratic scanning.
apps/desktop/src/main/sources.ts (1)

137-149: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

A malformed timemap.json produces a wrong refusal message.

readTimeMap casts the parsed JSON to TimeMap without checking its shape. If compressedDurationNs is missing, inRange compares against undefined, returns false, and locateCitation reports "past the end of this recording" for a citation that is in range. Treat an unusable time map like an absent one.

♻️ Proposed change
 function readTimeMap(dir: string): TimeMap | null {
   try {
     const file = join(dir, "timemap.json");
     if (!existsSync(file)) return null;
-    return JSON.parse(readFileSync(file, "utf8")) as TimeMap;
+    const map = JSON.parse(readFileSync(file, "utf8")) as Partial<TimeMap>;
+    return typeof map.compressedDurationNs === "number" ? (map as TimeMap) : null;
   } catch {
     return null;
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/sources.ts` around lines 137 - 149, Update readTimeMap
to validate the parsed JSON has a usable numeric compressedDurationNs before
returning it; return null for missing, non-numeric, or otherwise invalid values
so callers treat malformed timemap.json as absent. Keep inRange unchanged and
preserve the existing valid TimeMap path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/desktop/src/main/edit.ts`:
- Around line 306-335: Include INDEX_PAGE in the touched paths used by
renamePage before calling snapshotOf, while avoiding duplicate entries when it
is already present from link rewriting. Preserve the existing rewrite tracking
and ensure the snapshot captures wiki/index.md unconditionally before
registerInIndex updates it.

In `@apps/desktop/src/main/ingest.ts`:
- Around line 47-58: Update ingestFile and the CHANNELS.drop flow to validate
renderer-supplied paths against the files recorded for the active drop,
rejecting any unapproved source before ingestion. Enforce the 64 MiB source-size
limit using file metadata before readFile, while preserving the existing
recognized-extension checks and DropOutcome error handling.

In `@apps/desktop/src/main/ipc.ts`:
- Around line 239-242: Update the CHANNELS.save and CHANNELS.create branches in
dispatch to validate renderer payloads instead of casting them directly. Add
dedicated coercion helpers near dispatch that normalize object fields to
strings, then construct SaveInput with slug, markdown, and baseMarkdown and
CreateInput with slug and markdown before calling api.save or api.create.

In `@apps/desktop/src/main/preload.ts`:
- Around line 77-82: Replace the incomplete _parity cast with a Record<keyof
OwBridge, unknown> assignment directly from api, so TypeScript requires api to
provide every OwBridge key while preserving the compile-time parity check.

In `@apps/desktop/src/renderer/App.tsx`:
- Around line 419-424: Update the rename success handling in App’s rename flow
so repointed-link notices do not call onError or render in the error state.
Route the message from result.repointed through the existing non-error notice
mechanism, such as the Dropped-style notice area, while preserving the visit to
the renamed wiki slug.
- Around line 176-182: Update the drop-processing logic around pathForFile so
unresolved files remain represented in the drop report instead of being removed
by filter(Boolean). Preserve resolved paths for normal processing, and add an
explicit unsuccessful outcome for each file whose path cannot be resolved so the
final counts and messages include every dropped file.

In `@apps/desktop/src/renderer/Editor.tsx`:
- Around line 72-74: Update the Cancel button’s onCancel flow in Editor so it
compares the current markdown with base and prompts for confirmation when they
differ before discarding edits; preserve the existing immediate cancellation
behavior when there are no changes or the user confirms.

In `@apps/desktop/src/renderer/Panels.tsx`:
- Around line 22-30: Update the findings-loading effect in Panels.tsx so IPC or
checkProject failures remain distinct from a successful empty findings result;
do not call setFindings([]) from the catch. Add or reuse an explicit error state
and render an error message for failures, while preserving “Nothing to fix.”
only when findings resolves to an empty array.
- Around line 174-177: Update fileUrl to encode each path segment before
constructing the file URL, preventing characters such as #, ?, and % from
altering URL parsing. Preserve Windows drive-letter syntax and the existing
slash normalization while ensuring the resulting URL still points to the
intended local file.

In `@apps/desktop/src/renderer/Sources.tsx`:
- Around line 93-103: Replace the citation `<a>` elements in the
`row.citedBy.map` rendering with keyboard-operable buttons that call
`onOpenPage(slug)`, and apply the `linkish` class to preserve link-like styling.
Add the matching `.linkish` rule to the renderer tokens stylesheet, including
visible focus styling consistent with existing focus behavior.
- Around line 66-76: Update the retitle callback in Sources so failures from
bridge().retitle are caught and reported through the existing error state. Pass
the error reporter through the relevant component/call site so a rejected
retitle reaches the screen, while preserving the finally block that clears busy
and the current cancellation behavior.

In `@apps/desktop/src/renderer/tokens.css`:
- Around line 468-480: Update the .source-at style rule to bound its height
relative to the viewport, using the existing spacing variables, and enable
internal scrolling for content that exceeds that limit. Preserve its fixed
bottom-right positioning and current width behavior.

In `@packages/access/src/write/undo.ts`:
- Around line 54-62: Update the undo loop around assertWithin and isUndoable so
the undoability check uses the confined destination, not the raw page.path:
derive the project-relative path from live using the appropriate path utility,
then pass that normalized relative path to isUndoable before restoring or
removing. Import the required relative-path helper and resolveReal alongside
assertWithin, preserving the existing containment validation and corruption
error behavior.

In `@plans/open-wiki.md`:
- Around line 243-245: Remove the stale “Deferred to group 8” note beneath
checklist item 7.6 in plans/open-wiki.md, preserving the completed 7.6 entry and
its single explanatory note without changing other plan content.

---

Nitpick comments:
In `@apps/desktop/src/main/sources.ts`:
- Around line 137-149: Update readTimeMap to validate the parsed JSON has a
usable numeric compressedDurationNs before returning it; return null for
missing, non-numeric, or otherwise invalid values so callers treat malformed
timemap.json as absent. Keep inRange unchanged and preserve the existing valid
TimeMap path.

In `@apps/desktop/src/renderer/App.tsx`:
- Around line 335-341: Update the outcomes.map rendering in App.tsx to construct
each list item's key from both outcome.name and the map index, ensuring
duplicate basenames from different files remain unique while preserving the
existing displayed content.

In `@apps/desktop/src/renderer/Editor.tsx`:
- Around line 104-112: Add a concise explanatory comment immediately before the
dangerouslySetInnerHTML usage in the preview article, matching the rationale
documented in App.tsx: preview HTML is produced by renderPageBody with html:
false and a closed set of allowed tags. Do not change the rendering behavior.

In `@apps/desktop/src/renderer/Panels.tsx`:
- Around line 133-140: Update the useEffect containing bridge().locate so it
tracks whether the current effect is still active and ignores both successful
and failed results after cleanup. Set the location only while the request
corresponds to the current id and fragment, and return an effect cleanup that
marks the request inactive.

In `@apps/desktop/src/renderer/tokens.css`:
- Around line 405-410: Update the .editor__panes grid to provide a single-column
fallback at narrow widths, while preserving the two-column layout when
sufficient space is available. Use a responsive grid definition or media query
consistent with the existing citation panel’s min() width handling.

In `@apps/desktop/tests/edit.spec.ts`:
- Around line 92-96: Update the assertions around the saved-result checks in the
test cases near the existing guard and the matching case near lines 182-185 to
assert that result.reason is exactly "invalid" before accessing result.problems.
Remove the conditional guard so an unexpected refusal reason fails the test
rather than skipping the problem-content assertion.
- Around line 443-450: Update the “is fast on a long page” test around renameId
to avoid a machine-dependent 1000 ms wall-clock assertion: either use a
substantially more generous threshold with the existing large input or assert
the renamed output while retaining a sufficiently large input to catch quadratic
scanning.

In `@packages/audio/src/index.ts`:
- Line 10: Add an "./atomic" subpath export in packages/audio/package.json
pointing to the atomic module, and update the package entry point to import
writeAtomic from that dedicated subpath instead of re-exporting through the
barrel. Keep the atomic export isolated so consumers do not load ffmpeg.js or
node:child_process.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ed55316d-65f1-49c1-872c-ee35c509ebc5

📥 Commits

Reviewing files that changed from the base of the PR and between fbaba09 and adccc19.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (20)
  • apps/desktop/package.json
  • apps/desktop/src/main/edit.ts
  • apps/desktop/src/main/ingest.ts
  • apps/desktop/src/main/ipc.ts
  • apps/desktop/src/main/preload.ts
  • apps/desktop/src/main/sources.ts
  • apps/desktop/src/renderer/App.tsx
  • apps/desktop/src/renderer/Editor.tsx
  • apps/desktop/src/renderer/Panels.tsx
  • apps/desktop/src/renderer/Sources.tsx
  • apps/desktop/src/renderer/bridge.ts
  • apps/desktop/src/renderer/index.html
  • apps/desktop/src/renderer/navigation.ts
  • apps/desktop/src/renderer/tokens.css
  • apps/desktop/tests/edit.spec.ts
  • apps/desktop/tests/sources.spec.ts
  • packages/access/src/index.ts
  • packages/access/src/write/undo.ts
  • packages/audio/src/index.ts
  • plans/open-wiki.md

Comment on lines +306 to +335
const touched = [ref.path, target];
const repointed: string[] = [];
const rewrites: Array<{ path: string; content: string }> = [];
// `listPages` deliberately excludes `index.md`, `changelog.md` and `log.md`
// — they are not entity pages. They do carry wikilinks, and `index.md` is
// guaranteed to link to this page because `registerInIndex` put it there, so
// a repoint that skipped them left a broken link in the one file certain to
// have one.
// `NON_ENTITY_PAGES` are bare filenames; they live at the top of `wiki/`.
const linkers = [
...listPages(projectRoot).map((p) => p.path),
...NON_ENTITY_PAGES.map((name) => `wiki/${name}`),
];
for (const path of linkers) {
if (path === ref.path) continue;
const file = assertWithin(projectRoot, join(projectRoot, path));
if (!existsSync(file)) continue;
const body = readFileSync(file, "utf8");
const next = body.replace(wikilinkTo(from), `[[${to}$1`);
if (next !== body) {
rewrites.push({ path, content: next });
touched.push(path);
repointed.push(path.replace(/^wiki\//, "").replace(/\.md$/, ""));
}
}

const operation = appendOperation(projectRoot, {
...snapshotOf(projectRoot, touched),
origin: "editor",
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Include wiki/index.md in the rename snapshot.

renamePage calls registerInIndex(projectRoot, to) at line 359, after the snapshot is taken. wiki/index.md enters touched only when it contained a [[from]] link that was rewritten. If index.md had no link to from, the new index entry for to is written outside the operation. Undo then removes the renamed page and leaves the index entry pointing at it, which is the broken wikilink createPage avoids by snapshotting INDEX_PAGE unconditionally (lines 185-192).

Add INDEX_PAGE to the snapshot paths. snapshot records existed per path, so including it twice is the only thing to avoid.

🛠️ Proposed fix
-  const touched = [ref.path, target];
+  // `registerInIndex` touches `index.md` after the snapshot, so it is part of
+  // the operation even when no wikilink in it had to be repointed.
+  const touched = [ref.path, target, INDEX_PAGE];
@@
     if (next !== body) {
       rewrites.push({ path, content: next });
-      touched.push(path);
+      if (path !== INDEX_PAGE) touched.push(path);
       repointed.push(path.replace(/^wiki\//, "").replace(/\.md$/, ""));
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const touched = [ref.path, target];
const repointed: string[] = [];
const rewrites: Array<{ path: string; content: string }> = [];
// `listPages` deliberately excludes `index.md`, `changelog.md` and `log.md`
// — they are not entity pages. They do carry wikilinks, and `index.md` is
// guaranteed to link to this page because `registerInIndex` put it there, so
// a repoint that skipped them left a broken link in the one file certain to
// have one.
// `NON_ENTITY_PAGES` are bare filenames; they live at the top of `wiki/`.
const linkers = [
...listPages(projectRoot).map((p) => p.path),
...NON_ENTITY_PAGES.map((name) => `wiki/${name}`),
];
for (const path of linkers) {
if (path === ref.path) continue;
const file = assertWithin(projectRoot, join(projectRoot, path));
if (!existsSync(file)) continue;
const body = readFileSync(file, "utf8");
const next = body.replace(wikilinkTo(from), `[[${to}$1`);
if (next !== body) {
rewrites.push({ path, content: next });
touched.push(path);
repointed.push(path.replace(/^wiki\//, "").replace(/\.md$/, ""));
}
}
const operation = appendOperation(projectRoot, {
...snapshotOf(projectRoot, touched),
origin: "editor",
});
// `registerInIndex` touches `index.md` after the snapshot, so it is part of
// the operation even when no wikilink in it had to be repointed.
const touched = [ref.path, target, INDEX_PAGE];
const repointed: string[] = [];
const rewrites: Array<{ path: string; content: string }> = [];
// `listPages` deliberately excludes `index.md`, `changelog.md` and `log.md`
// — they are not entity pages. They do carry wikilinks, and `index.md` is
// guaranteed to link to this page because `registerInIndex` put it there, so
// a repoint that skipped them left a broken link in the one file certain to
// have one.
// `NON_ENTITY_PAGES` are bare filenames; they live at the top of `wiki/`.
const linkers = [
...listPages(projectRoot).map((p) => p.path),
...NON_ENTITY_PAGES.map((name) => `wiki/${name}`),
];
for (const path of linkers) {
if (path === ref.path) continue;
const file = assertWithin(projectRoot, join(projectRoot, path));
if (!existsSync(file)) continue;
const body = readFileSync(file, "utf8");
const next = body.replace(wikilinkTo(from), `[[${to}$1`);
if (next !== body) {
rewrites.push({ path, content: next });
if (path !== INDEX_PAGE) touched.push(path);
repointed.push(path.replace(/^wiki\//, "").replace(/\.md$/, ""));
}
}
const operation = appendOperation(projectRoot, {
...snapshotOf(projectRoot, touched),
origin: "editor",
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/edit.ts` around lines 306 - 335, Include INDEX_PAGE in
the touched paths used by renamePage before calling snapshotOf, while avoiding
duplicate entries when it is already present from link rewriting. Preserve the
existing rewrite tracking and ensure the snapshot captures wiki/index.md
unconditionally before registerInIndex updates it.

Comment on lines +47 to +58
export async function ingestFile(projectRoot: string, path: string): Promise<DropOutcome> {
const name = basename(path);
const adapter = ADAPTERS[extname(name).toLowerCase()];
if (!adapter) {
return {
name,
ok: false,
reason: `open-wiki takes ${recognisedExtensions().join(", ")} — not ${extname(name) || "a file with no extension"}`,
};
}
try {
const content = await readFile(path);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for any validation of dropped paths on the main-process side.
fd -e ts -e tsx . apps/desktop/src --exec rg -n -C3 'getPathForFile|CHANNELS\.drop|ingestDrop|ingestFile'
# Look for an existing path-allowlist or size-limit helper in the access package.
rg -n -C3 'maxBytes|MAX_[A-Z_]*SIZE|assertWithin' packages/access/src | head -60

Repository: protonspy/open-wiki

Length of output: 7932


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- ingest.ts ---'
cat -n apps/desktop/src/main/ingest.ts | sed -n '1,125p'
printf '%s\n' '--- ipc.ts ---'
cat -n apps/desktop/src/main/ipc.ts | sed -n '180,285p'
printf '%s\n' '--- preload and renderer drop flow ---'
cat -n apps/desktop/src/preload/index.ts 2>/dev/null | sed -n '1,90p' || true
rg -n -C8 'pathForFile|dataTransfer|drop\(|CHANNELS\.drop|bridge\(\)' apps/desktop/src --glob '*.ts' --glob '*.tsx'
printf '%s\n' '--- path helpers and package metadata ---'
cat -n packages/access/src/paths.ts | sed -n '1,110p'
rg -n 'electron|webUtils|ingestFile|ingestDrop|raw/' apps/desktop package.json apps/desktop/package.json packages --glob 'package.json' --glob '*.ts' --glob '*.tsx' | head -160

Repository: protonspy/open-wiki

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
p = Path("apps/desktop/src/main/ingest.ts")
s = p.read_text()
print("--- ingest behavior summary ---")
for i, line in enumerate(s.splitlines(), 1):
    if 1 <= i <= 120:
        print(f"{i}: {line}")
PY

Repository: protonspy/open-wiki

Length of output: 3852


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- upload and registration path ---'
cat -n packages/access/src/sources/upload.ts | sed -n '1,180p'
cat -n packages/access/src/sources/register.ts | sed -n '1,180p'
printf '%s\n' '--- relevant desktop tests ---'
cat -n apps/desktop/tests/sources.spec.ts | sed -n '205,270p'
printf '%s\n' '--- renderer trust and window security configuration ---'
rg -n -C5 'loadURL|loadFile|contextIsolation|sandbox|nodeIntegration|webSecurity|preload|setWindowOpenHandler|will-navigate|ipcMain\.handle' apps/desktop/src/main apps/desktop/src --glob '*.ts' --glob '*.tsx'
printf '%s\n' '--- size-related source APIs ---'
rg -n -C3 'readFile\(|stat\(|size|limit|maxBytes|MAX_' packages/access/src/sources apps/desktop/src/main/ingest.ts

Repository: protonspy/open-wiki

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
for name in [
    "packages/access/src/sources/upload.ts",
    "packages/access/src/sources/register.ts",
]:
    print(f"--- {name} ---")
    lines = Path(name).read_text().splitlines()
    for i, line in enumerate(lines, 1):
        print(f"{i}: {line}")
PY

Repository: protonspy/open-wiki

Length of output: 8396


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re

ingest = Path("apps/desktop/src/main/ingest.ts").read_text()
upload = Path("packages/access/src/sources/upload.ts").read_text()

# Reproduce the extension gate used by the desktop drop path for representative
# readable paths. This is a read-only behavioral probe of the source rules.
adapters = {".md", ".markdown", ".txt", ".pdf", ".docx"}
def accepted(path):
    name = path.replace("\\", "/").rsplit("/", 1)[-1]
    dot = name.rfind(".")
    ext = name[dot:].lower() if dot >= 0 else ""
    return ext in adapters

for path in [
    "/home/user/.ssh/id_rsa",
    "/home/user/.config/app/token.txt",
    "/home/user/Documents/notes.md",
    "/tmp/archive.pdf",
    "/tmp/unknown.bin",
]:
    print(f"{path}: {'accepted' if accepted(path) else 'rejected'}")

print("desktop reads path directly:", "readFile(path)" in ingest)
print("desktop imports MAX_SOURCE_BYTES:", "MAX_SOURCE_BYTES" in ingest)
print("shared upload defines MAX_SOURCE_BYTES:", bool(re.search(r"MAX_SOURCE_BYTES\\s*=\\s*64 \\* 1024 \\* 1024", upload)))
PY

Repository: protonspy/open-wiki

Length of output: 447


Restrict renderer-supplied paths before ingestion.

CHANNELS.drop accepts arbitrary renderer-supplied strings, and ingestFile reads recognized-extension paths without validating their origin. A compromised renderer can ingest any readable .md, .markdown, .txt, .pdf, or .docx file into raw/. Allow only files recorded for the active drop, and enforce the 64 MiB source limit before readFile.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/ingest.ts` around lines 47 - 58, Update ingestFile and
the CHANNELS.drop flow to validate renderer-supplied paths against the files
recorded for the active drop, rejecting any unapproved source before ingestion.
Enforce the 64 MiB source-size limit using file metadata before readFile, while
preserving the existing recognized-extension checks and DropOutcome error
handling.

Comment on lines +239 to +242
case CHANNELS.save:
return api.save(args[0] as SaveInput);
case CHANNELS.create:
return api.create(args[0] as CreateInput);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate the save and create payloads at the trust boundary.

Every other case in dispatch coerces its arguments. These two cases cast an arbitrary renderer value with as SaveInput / as CreateInput, so no field is checked. savePage in apps/desktop/src/main/edit.ts Lines 109-136 then passes input.markdown to gateWrite and on to disk. A non-string markdown reaches the write path, and a missing baseMarkdown defeats the 8.8 staleness check because isStale compares against a value the renderer never loaded.

Coerce the three fields the same way the other channels coerce their arguments.

🛡️ Proposed fix
     case CHANNELS.save:
-      return api.save(args[0] as SaveInput);
+      return api.save(saveInput(args[0]));
     case CHANNELS.create:
-      return api.create(args[0] as CreateInput);
+      return api.create(createInput(args[0]));

Add the helpers beside dispatch:

function fields(value: unknown): Record<string, unknown> {
  return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
}

function text(value: unknown): string {
  return typeof value === "string" ? value : "";
}

function saveInput(value: unknown): SaveInput {
  const raw = fields(value);
  return {
    slug: text(raw["slug"]),
    markdown: text(raw["markdown"]),
    baseMarkdown: text(raw["baseMarkdown"]),
  };
}

function createInput(value: unknown): CreateInput {
  const raw = fields(value);
  return { slug: text(raw["slug"]), markdown: text(raw["markdown"]) };
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case CHANNELS.save:
return api.save(args[0] as SaveInput);
case CHANNELS.create:
return api.create(args[0] as CreateInput);
case CHANNELS.save:
return api.save(saveInput(args[0]));
case CHANNELS.create:
return api.create(createInput(args[0]));
function fields(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: {};
}
function text(value: unknown): string {
return typeof value === "string" ? value : "";
}
function saveInput(value: unknown): SaveInput {
const raw = fields(value);
return {
slug: text(raw["slug"]),
markdown: text(raw["markdown"]),
baseMarkdown: text(raw["baseMarkdown"]),
};
}
function createInput(value: unknown): CreateInput {
const raw = fields(value);
return { slug: text(raw["slug"]), markdown: text(raw["markdown"]) };
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/ipc.ts` around lines 239 - 242, Update the
CHANNELS.save and CHANNELS.create branches in dispatch to validate renderer
payloads instead of casting them directly. Add dedicated coercion helpers near
dispatch that normalize object fields to strings, then construct SaveInput with
slug, markdown, and baseMarkdown and CreateInput with slug and markdown before
calling api.save or api.create.

Comment on lines +77 to +82
/**
* The parity check. If `OwBridge` gains a method this object does not have,
* this line stops compiling — which is the whole point of it existing.
*/
const _parity: (keyof OwBridge)[] = Object.keys(api) as (keyof OwBridge)[];
void _parity;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm no other parity assertion exists and inspect the current one.
rg -n -C4 '_parity|keyof OwBridge|satisfies OwBridge' apps/desktop/src

Repository: protonspy/open-wiki

Length of output: 620


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- preload.ts structure ---'
ast-grep outline apps/desktop/src/main/preload.ts
printf '%s\n' '--- preload.ts relevant source ---'
sed -n '1,105p' apps/desktop/src/main/preload.ts
printf '%s\n' '--- OwBridge declarations and api construction ---'
rg -n -C5 'interface OwBridge|type OwBridge|const api|contextBridge|OwBridge' apps/desktop/src
printf '%s\n' '--- package TypeScript configuration ---'
fd -i 'tsconfig*.json' . -x sh -c 'echo "--- $1"; sed -n "1,220p" "$1"' sh '{}'

Repository: protonspy/open-wiki

Length of output: 11552


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- OwBridge members ---'
sed -n '1,55p' apps/desktop/src/renderer/bridge.ts
printf '%s\n' '--- base compiler options ---'
sed -n '1,180p' tsconfig.base.json
printf '%s\n' '--- structural key comparison ---'
python3 - <<'PY'
from pathlib import Path
import re

preload = Path("apps/desktop/src/main/preload.ts").read_text()
bridge = Path("apps/desktop/src/renderer/bridge.ts").read_text()

api_block = re.search(r'const api = \{(.*?)\n\};', preload, re.S).group(1)
api_keys = re.findall(r'^\s{2}([A-Za-z_$][\w$]*)\s*:', api_block, re.M)
bridge_block = re.search(r'interface OwBridge\s*\{(.*?)\n\}', bridge, re.S).group(1)
bridge_keys = re.findall(r'^\s{2}([A-Za-z_$][\w$]*)\s*\(', bridge_block, re.M)

print("api keys:", api_keys)
print("OwBridge keys:", bridge_keys)
print("missing from api:", sorted(set(bridge_keys) - set(api_keys)))
print("extra in api:", sorted(set(api_keys) - set(bridge_keys)))
PY
printf '%s\n' '--- TypeScript tooling availability (read-only) ---'
command -v tsc || true
node -e 'try { console.log(require.resolve("typescript")) } catch (_) { console.log("typescript package unavailable") }' 2>/dev/null || true

Repository: protonspy/open-wiki

Length of output: 3568


🏁 Script executed:

#!/bin/bash
set -e
node - <<'JS'
const ts = require("/home/jailuser/git/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/typescript.js");

const source = `
type OwBridge = {
  project(): void;
  index(): void;
};

const api = { project() {} };
const castCheck: (keyof OwBridge)[] =
  Object.keys(api) as (keyof OwBridge)[];
const completeCheck: Record<keyof OwBridge, unknown> = api;
const extraKeyApi = { project() {}, index() {}, extra() {} };
const extraKeysAllowed: Record<keyof OwBridge, unknown> = extraKeyApi;
`;

const fileName = "/virtual/parity.ts";
const options = {
  strict: true,
  noEmit: true,
  target: ts.ScriptTarget.ES2023,
  lib: ["lib.es2023.d.ts"],
};
const host = ts.createCompilerHost(options);
const originalGetSourceFile = host.getSourceFile.bind(host);
host.getSourceFile = (name, languageVersion, onError, shouldCreateNewSourceFile) => {
  if (name === fileName) {
    return ts.createSourceFile(name, source, languageVersion, true);
  }
  return originalGetSourceFile(name, languageVersion, onError, shouldCreateNewSourceFile);
};
host.fileExists = (name) => name === fileName || ts.sys.fileExists(name);
host.readFile = (name) => name === fileName ? source : ts.sys.readFile(name);

const program = ts.createProgram([fileName], options, host);
const diagnostics = ts.getPreEmitDiagnostics(program);
for (const diagnostic of diagnostics) {
  const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
  const start = diagnostic.start == null ? "" : ` at offset ${diagnostic.start}`;
  console.log(`${message}${start}`);
}
console.log(`diagnostic count: ${diagnostics.length}`);
JS

Repository: protonspy/open-wiki

Length of output: 1087


Use a complete-key parity check for OwBridge.

Object.keys(api) as (keyof OwBridge)[] only casts string[]; it does not require every OwBridge key. Use const _parity: Record<keyof OwBridge, unknown> = api.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/preload.ts` around lines 77 - 82, Replace the
incomplete _parity cast with a Record<keyof OwBridge, unknown> assignment
directly from api, so TypeScript requires api to provide every OwBridge key
while preserving the compile-time parity check.

Comment on lines +176 to +182
const paths = [...event.dataTransfer.files].map((file) => ow.pathForFile(file)).filter(Boolean);
if (paths.length === 0) {
setDropped([
{ name: "that drop", ok: false, reason: "nothing in it looked like a file on disk" },
]);
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

A file whose path does not resolve disappears from the drop report.

filter(Boolean) removes every path that pathForFile could not resolve. If a drop contains five files and two resolve, the report lists two outcomes and says "2 of 2 added". The unresolved files are not mentioned. Plan 3.5 asks for what was not recognised as well.

♻️ Proposed change
     const ow = bridge();
-    const paths = [...event.dataTransfer.files].map((file) => ow.pathForFile(file)).filter(Boolean);
-    if (paths.length === 0) {
-      setDropped([
-        { name: "that drop", ok: false, reason: "nothing in it looked like a file on disk" },
-      ]);
-      return;
-    }
+    const resolved = [...event.dataTransfer.files].map((file) => ({
+      name: file.name,
+      path: ow.pathForFile(file),
+    }));
+    const unresolved: DropOutcome[] = resolved
+      .filter((entry) => !entry.path)
+      .map((entry) => ({
+        name: entry.name || "that drop",
+        ok: false,
+        reason: "it did not look like a file on disk",
+      }));
+    const paths = resolved.map((entry) => entry.path).filter(Boolean);
+    if (paths.length === 0) {
+      setDropped(
+        unresolved.length > 0
+          ? unresolved
+          : [{ name: "that drop", ok: false, reason: "nothing in it looked like a file on disk" }],
+      );
+      return;
+    }
     void ow
       .drop(paths)
       .then((outcomes) => {
-        setDropped(outcomes);
+        setDropped([...outcomes, ...unresolved]);
         setReloadKey((n) => n + 1);
       })
       .catch((e: unknown) => setError(message(e)));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const paths = [...event.dataTransfer.files].map((file) => ow.pathForFile(file)).filter(Boolean);
if (paths.length === 0) {
setDropped([
{ name: "that drop", ok: false, reason: "nothing in it looked like a file on disk" },
]);
return;
}
const ow = bridge();
const resolved = [...event.dataTransfer.files].map((file) => ({
name: file.name,
path: ow.pathForFile(file),
}));
const unresolved: DropOutcome[] = resolved
.filter((entry) => !entry.path)
.map((entry) => ({
name: entry.name || "that drop",
ok: false,
reason: "it did not look like a file on disk",
}));
const paths = resolved.map((entry) => entry.path).filter(Boolean);
if (paths.length === 0) {
setDropped(
unresolved.length > 0
? unresolved
: [{ name: "that drop", ok: false, reason: "nothing in it looked like a file on disk" }],
);
return;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/App.tsx` around lines 176 - 182, Update the
drop-processing logic around pathForFile so unresolved files remain represented
in the drop report instead of being removed by filter(Boolean). Preserve
resolved paths for normal processing, and add an explicit unsuccessful outcome
for each file whose path cannot be resolved so the final counts and messages
include every dropped file.

Comment on lines +66 to +76
const retitle = useCallback(async () => {
const next = globalThis.prompt?.("Title for this source", row.title);
if (!next || next === row.title) return;
setBusy(true);
try {
await bridge().retitle(row.id, next);
onChanged();
} finally {
setBusy(false);
}
}, [row.id, row.title, onChanged]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Report a failed retitle to the user.

retitle has no catch. finally clears busy, then the rejection escapes an async callback invoked as void retitle(). The row shows no message and no change, so a refused retitle is indistinguishable from a cancelled prompt. Sources already holds an error state; pass a reporter down so the failure reaches the screen.

🛠️ Proposed fix
 function SourceItem({
   row,
   onOpenPage,
   onChanged,
+  onError,
 }: {
   row: SourceRow;
   onOpenPage: (slug: string) => void;
   onChanged: () => void;
+  onError: (message: string) => void;
 }): React.JSX.Element {
@@
     setBusy(true);
     try {
       await bridge().retitle(row.id, next);
       onChanged();
+    } catch (e) {
+      onError(e instanceof Error ? e.message : String(e));
     } finally {
       setBusy(false);
     }
-  }, [row.id, row.title, onChanged]);
+  }, [row.id, row.title, onChanged, onError]);

and at the call site:

-        <SourceItem key={row.id} row={row} onOpenPage={onOpenPage} onChanged={() => void load()} />
+        <SourceItem
+          key={row.id}
+          row={row}
+          onOpenPage={onOpenPage}
+          onChanged={() => void load()}
+          onError={setError}
+        />
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/Sources.tsx` around lines 66 - 76, Update the
retitle callback in Sources so failures from bridge().retitle are caught and
reported through the existing error state. Pass the error reporter through the
relevant component/call site so a rejected retitle reaches the screen, while
preserving the finally block that clears busy and the current cancellation
behavior.

Comment on lines +93 to +103
{row.citedBy.length > 0 ? (
<p className="source__cited">
Cited by{" "}
{row.citedBy.map((slug, i) => (
<span key={slug}>
{i > 0 ? ", " : ""}
<a onClick={() => onOpenPage(slug)}>{slug}</a>
</span>
))}
</p>
) : null}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make the citation links keyboard-operable.

<a onClick={...}> carries no href, so it is not in the tab order and it does not respond to Enter or Space. Navigating from a source to a citing page is the whole of plan 6.4, and a keyboard user cannot complete it. Plan 8.1 also states focus is visible on everything, always.

Use a button and style it as a link.

♿ Proposed fix
           {row.citedBy.map((slug, i) => (
             <span key={slug}>
               {i > 0 ? ", " : ""}
-              <a onClick={() => onOpenPage(slug)}>{slug}</a>
+              <button type="button" className="linkish" onClick={() => onOpenPage(slug)}>
+                {slug}
+              </button>
             </span>
           ))}

Add the matching rule to apps/desktop/src/renderer/tokens.css:

.linkish {
  background: none;
  border: 0;
  padding: 0;
  color: var(--accent);
  font: inherit;
  text-decoration: underline;
  cursor: pointer;
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{row.citedBy.length > 0 ? (
<p className="source__cited">
Cited by{" "}
{row.citedBy.map((slug, i) => (
<span key={slug}>
{i > 0 ? ", " : ""}
<a onClick={() => onOpenPage(slug)}>{slug}</a>
</span>
))}
</p>
) : null}
{row.citedBy.length > 0 ? (
<p className="source__cited">
Cited by{" "}
{row.citedBy.map((slug, i) => (
<span key={slug}>
{i > 0 ? ", " : ""}
<button type="button" className="linkish" onClick={() => onOpenPage(slug)}>
{slug}
</button>
</span>
))}
</p>
) : null}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/Sources.tsx` around lines 93 - 103, Replace the
citation `<a>` elements in the `row.citedBy.map` rendering with
keyboard-operable buttons that call `onOpenPage(slug)`, and apply the `linkish`
class to preserve link-like styling. Add the matching `.linkish` rule to the
renderer tokens stylesheet, including visible focus styling consistent with
existing focus behavior.

Comment on lines +468 to +480
/* --- What a citation opens (8.6), beside the page rather than over it. */
.source-at {
position: fixed;
right: var(--space-4);
bottom: var(--space-4);
width: min(480px, calc(100vw - var(--space-8)));
background: var(--surface-2);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
padding: var(--space-3);
display: grid;
gap: var(--space-2);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Bound the height of the citation panel.

.source-at is fixed to the bottom right with no max-height and no overflow. A long cited passage grows the panel upward past the top of the viewport, and the part above the fold cannot be reached or scrolled. The width is already bounded with min(); bound the height the same way.

🎨 Proposed fix
   width: min(480px, calc(100vw - var(--space-8)));
+  max-height: calc(100vh - var(--space-8));
+  overflow: auto;
   background: var(--surface-2);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/* --- What a citation opens (8.6), beside the page rather than over it. */
.source-at {
position: fixed;
right: var(--space-4);
bottom: var(--space-4);
width: min(480px, calc(100vw - var(--space-8)));
background: var(--surface-2);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
padding: var(--space-3);
display: grid;
gap: var(--space-2);
}
/* --- What a citation opens (8.6), beside the page rather than over it. */
.source-at {
position: fixed;
right: var(--space-4);
bottom: var(--space-4);
width: min(480px, calc(100vw - var(--space-8)));
max-height: calc(100vh - var(--space-8));
overflow: auto;
background: var(--surface-2);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
padding: var(--space-3);
display: grid;
gap: var(--space-2);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/tokens.css` around lines 468 - 480, Update the
.source-at style rule to bound its height relative to the viewport, using the
existing spacing variables, and enable internal scrolling for content that
exceeds that limit. Preserve its fixed bottom-right positioning and current
width behavior.

Comment on lines 54 to +62
for (const page of op.pages) {
// The paths come off the operation log on disk, so they are input, not
// fact: confine both ends before restoring or removing anything. Undo is
// the one operation that deletes, and a `..` in a log entry must not turn
// it into a delete of something the project never owned.
const live = assertWithin(projectRoot, join(projectRoot, page.path));
if (!isUndoable(page.path)) {
throw new CorruptOperationError(id, `it names "${page.path}", which undo does not own`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

isUndoable checks the unresolved path, so .. defeats the guard.

isUndoable tests a prefix on the raw page.path string. assertWithin only proves the path stays inside the project. A log entry of wiki/../.git/config satisfies both checks: the prefix is wiki/, and .git/config is inside projectRoot. With existed: false that entry still reaches rmSync, which is the exact case the doc comment says is now refused. wiki/../.state/log.jsonl, wiki/../CLAUDE.md and wiki/../.claude/rules/autonomy.md pass the same way. The backslash normalisation in isUndoable does not help, because it converts wiki\..\.git\config into the same passing string.

Derive the relative path from the confined absolute path and test that, so the prefix check sees where the write lands rather than the name it was asked for.

🛡️ Proposed fix
   for (const page of op.pages) {
     const live = assertWithin(projectRoot, join(projectRoot, page.path));
-    if (!isUndoable(page.path)) {
+    // Check where it *lands*, not the name it was asked for: `wiki/../.git/config`
+    // is confined by `assertWithin` and still leaves `wiki/` behind.
+    if (!isUndoable(relative(resolveReal(projectRoot), live))) {
       throw new CorruptOperationError(id, `it names "${page.path}", which undo does not own`);
     }

Add the imports:

-import { join, dirname } from "node:path";
+import { join, dirname, relative } from "node:path";

and import resolveReal from ../paths.js beside assertWithin.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const page of op.pages) {
// The paths come off the operation log on disk, so they are input, not
// fact: confine both ends before restoring or removing anything. Undo is
// the one operation that deletes, and a `..` in a log entry must not turn
// it into a delete of something the project never owned.
const live = assertWithin(projectRoot, join(projectRoot, page.path));
if (!isUndoable(page.path)) {
throw new CorruptOperationError(id, `it names "${page.path}", which undo does not own`);
}
for (const page of op.pages) {
// The paths come off the operation log on disk, so they are input, not
// fact: confine both ends before restoring or removing anything. Undo is
// the one operation that deletes, and a `..` in a log entry must not turn
// it into a delete of something the project never owned.
const live = assertWithin(projectRoot, join(projectRoot, page.path));
// Check where it *lands*, not the name it was asked for: `wiki/../.git/config`
// is confined by `assertWithin` and still leaves `wiki/` behind.
if (!isUndoable(relative(resolveReal(projectRoot), live))) {
throw new CorruptOperationError(id, `it names "${page.path}", which undo does not own`);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/access/src/write/undo.ts` around lines 54 - 62, Update the undo loop
around assertWithin and isUndoable so the undoability check uses the confined
destination, not the raw page.path: derive the project-relative path from live
using the appropriate path utility, then pass that normalized relative path to
isUndoable before restoring or removing. Import the required relative-path
helper and resolveReal alongside assertWithin, preserving the existing
containment validation and corruption error behavior.

Comment thread plans/open-wiki.md
Comment on lines +243 to 245
- [x] 7.6 (Unit) Expose the checks in the UI, with the correction path described per finding
- Rendering, not new checking, exactly as the deferral said. Every finding already carries its `fix`, and the panel puts it where the person reading the problem is.
- Deferred to group 8, which is where the UI is. The findings already carry the correction path — `fix` on every one — so this is rendering, not new checking.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the stale deferral note under 7.6.

Line 244 records 7.6 as done. Line 245 still states the task is deferred to group 8 and repeats the same point about fix. Keep one note, so the plan does not describe the item as both closed and pending.

As per coding guidelines: "Keep each plan in one plans/<name>.md file containing structure plus a checklist and/or spec references."

📝 Proposed fix
   - Rendering, not new checking, exactly as the deferral said. Every finding already carries its `fix`, and the panel puts it where the person reading the problem is.
-  - Deferred to group 8, which is where the UI is. The findings already carry the correction path — `fix` on every one — so this is rendering, not new checking.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- [x] 7.6 (Unit) Expose the checks in the UI, with the correction path described per finding
- Rendering, not new checking, exactly as the deferral said. Every finding already carries its `fix`, and the panel puts it where the person reading the problem is.
- Deferred to group 8, which is where the UI is. The findings already carry the correction path — `fix` on every one — so this is rendering, not new checking.
- [x] 7.6 (Unit) Expose the checks in the UI, with the correction path described per finding
- Rendering, not new checking, exactly as the deferral said. Every finding already carries its `fix`, and the panel puts it where the person reading the problem is.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plans/open-wiki.md` around lines 243 - 245, Remove the stale “Deferred to
group 8” note beneath checklist item 7.6 in plans/open-wiki.md, preserving the
completed 7.6 entry and its single explanatory note without changing other plan
content.

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant