feat(editor): a desktop markdown editor with Claude editing and gist drafts - #324
Conversation
New @soroush/editor workspace app (electron-vite + React 19): hardened BrowserWindow (contextIsolation, sandbox, no nodeIntegration), CSP response headers via a unit-tested builder, placeholder renderer, and lint/typecheck/vitest coverage wiring. Adds the root dev:editor script and fixes the workspace build-script allowlist key (allowBuilds -> onlyBuiltDependencies) so electron's binary postinstall is allowed to run.
Result-wrapped ipcMain handlers (native dialogs + injectable fs) with a shared channel/type contract, a sandboxed CJS preload exposing a typed editorAPI.file surface via contextBridge, a useDocument renderer hook with dirty tracking mirrored to main, and a close intercept that prompts before discarding unsaved changes.
… design-system theming - close #298 Replaces the plain-element shell with the markdown Control/Toolbar/Editor/ Preview compound, themed by createTheme(baseTheme, { syntax: syntaxDark }) since CodeBlock requires theme.syntax. Adds the AppToolbar (Button/ ButtonGroup/Typography) for file actions, self-hosted webfonts plus the package global reset via a GlobalStyles component, and full unit coverage for the new components.
…299 useUndoRedo keeps past/future snapshot stacks over the controlled document value, committing an undo step only after edits settle for 500ms so typing coalesces instead of producing one entry per keystroke. Bound to Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, and Ctrl/Cmd+Y plus enabled-aware toolbar buttons; history resets when a different file is loaded.
…lose #300 Main-process bridge spawns one-shot `claude -p --output-format json --permission-mode dontAsk --allowedTools ""` from the OS temp dir so no repo CLAUDE.md/hooks/MCP config bleeds in, reusing the signed-in user's own subscription (no --bare, no API key). Every flag is constant and all user content travels over stdin, so nothing user-controlled reaches the shell. The ClaudePanel side panel shows the live editor selection, takes an instruction, and splices the rewrite back over the selection as a single undo step; CLI-missing/exit/parse failures surface in the panel. Verified end-to-end against the real CLI.
…n menu - Playwright e2e: per-test Electron launch fixtures, four specs (render, file round-trip, undo/redo, dirty-close prompt), V8 coverage from both processes (page.coverage + NODE_V8_COVERAGE) aggregated through @soroush.tech/playwright-coverage into coverage/e2e/lcov.info, gated at 100% on the two unit-coverage carve-outs via coverage:check:e2e - main-process wiring extracted to unit-tested bootstrap.ts so src/main/index.ts stays branch-free glue the e2e can fully cover - File (New/Open/Save/Save As) and Edit (Undo/Redo + clipboard roles) commands moved into the application menu over a typed menu:action IPC channel; toolbar buttons and AppToolbar removed; View/Window kept as native role menus - fix dev-mode white page: the CSP now allows the inline react-refresh preamble Vite injects in dev (script-src 'unsafe-inline'); production stays 'self'-only
- ClaudePanel is now a full-width bar under the editor: with a selection Claude rewrites it in place; with none it edits the whole document — an empty document generates fresh content from the instruction alone (empty selections accepted by the bridge, CLI prompt extended). A caption under Ask Claude explains what enables it. - DocumentEditor gains a Live edit / Edit / Split / Preview toggle group (split by default), wiring @soroush.tech/markdown's new LiveEdit into the app; selection reporting stays scoped to the source-textarea modes - the document row scrolls with padding for TextInput's focus ring, and the source field caps at maxRows so its border box stays visible
Each cherry-picked editor commit carried feat/editor's lockfile, which predates the LiveEdit dependencies and the wrangler-tools extraction, so replaying them onto this branch reverted main's entries for packages/markdown, packages/wrangler-tools and workers/bench. Regenerated with pnpm install --lockfile-only, then formatted with prettier to match the checked-in style. pnpm install --frozen-lockfile passes again.
…ess token - close #320 - sign-in is a pasted fine-grained PAT scoped to "Gists: Read and write": no OAuth app to register and no client id to ship. fetchAccount resolves the account through GET /user, which validates the token in the same call, so nothing is stored until GitHub accepts it; a 401 names what to check instead of reporting a bare status - credentialStore encrypts { login, token, avatar } with the asynchronous safeStorage API - non-blocking, key-rotation aware via shouldReEncrypt (the file is rewritten under the new key), and the synchronous pair may be deprecated. It refuses to write at all when the OS reports no encryption rather than falling back to plaintext, so only ciphertext ever reaches userData/github-credentials.bin - the token crosses the bridge once, on sign-in, and never travels back: status returns only { login, avatar }. openTokenSettings always opens main's constant URL, so a renderer-supplied URL cannot reach shell.openExternal - the avatar is fetched at ?s=64 and inlined as a data: URI, keeping the renderer CSP at img-src 'self' data: with no remote image host allowed and no request leaving the app when the rail paints - the app gains a left Sidebar rail with the account row pinned to its bottom: GitHub's mark while signed out, the avatar once connected, and the rail's panel column holding the token form. The row is a Pressable rather than a SidebarItem - that takes its icon by registry name and the mark is a local asset, since a brand logo does not belong in a Material Symbols set - so it ports into the panel through the same public SidebarContext contract SidebarItem uses
…cal sandbox - close #321 - the rail gains Files and Gists above the account row, all three sharing one panel column: picking a gist switches the panel to its files, and opening a file loads it into the document tagged with where it came from - every change to a gist is staged, never sent: Save on a gist file stages its content (Save As still writes to disk), Add file stages an addition, the row's cross stages a deletion and a staged row's undo clears it. The description reads as text behind an Edit button, then edits over several lines with explicit Save and Cancel - Enter belongs to the text there. Rows carry A/M/D with a change count, Publish sends the whole draft as one PATCH, and Reset discards it behind a confirmation: it destroys work that exists nowhere else, which staging no longer does - the unsaved-changes prompt is two buttons, Save as draft and Discard, so switching files never forces a choice between losing work and publishing - several files can be staged in turn and published together. Escape keeps the work, and the document stays put whenever the save could not be completed - drafts persist to userData/gist-drafts.json keyed by gist id, as plain JSON: this is the user's own draft text, not a credential. A draft written before the description could be staged is still read, so no staged work is lost. The draft is dropped only once GitHub has it, so a failed publish keeps it - main announces every draft change back to the renderer, because the editor and the panel each hold a view of it and only one of them made the change - without it, saving in the editor left the panel showing a stale count and no way to publish - empty content publishes as a blank line: GitHub answers 422 for a gist file whose content is the empty string, so a file added and published before anything was typed into it could never have succeeded. A gist also cannot have zero files, which the 422 message now says - a truncated file is refetched whole from its raw URL rather than loaded partially, since saving a partial file would silently drop the rest - the document name and the window title follow the open file, with an unsaved marker, and Save is a visible button rather than only an accelerator
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe PR adds a complete Electron Markdown editor with secure startup, typed IPC, Claude editing, GitHub authentication and gist workflows, document editing, renderer UI, and unit/E2E test infrastructure. ChangesEditor application
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related issues
Possibly related PRs
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
The address for the rest of a truncated file arrives in a response rather than being written here. It was checked and then used as it came; now only its path is kept and the request is rebuilt from the host this file names, so anything else the URL carried - a query, a fragment, a host smuggled into the credentials - is dropped rather than sent. The fetch also refuses redirects, which was the one remaining way the request could have left GitHub. Also drops a second act() that only started a promise: there was nothing to flush, and the awaited calls after it do that work.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Benchmark resultsBaseline case:
|
| case | avg | p75 | alloc/iter | vs fastest |
|---|---|---|---|---|
| styled-system color() :: local-cjs | 428 ns | 427 ns | 248 B (least) | fastest |
| styled-system color() :: previous | 433 ns | 432 ns | 248 B (+0.0%) | +1.2% |
| styled-system color() :: local-mjs | 435 ns | 434 ns | 248 B (+0.0%) | +1.5% |
Bundle ReportBundle size has no change ✅ |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/editor/src/renderer/src/common/ClaudePanel/ClaudePanel.tsx (1)
40-45: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winPreserve instruction text entered during loading.
The button is disabled while loading, but
TextInputis not. A user can enter a second instruction during the first request. Line 44 then clears that new instruction when the first request succeeds.Disable
TextInputwhileisLoading, or clear only the instruction captured for the completed request.Suggested fix
<TextInput multiline fullWidth minRows={2} + disabled={isLoading} value={instruction}Also applies to: 78-86
🤖 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/editor/src/renderer/src/common/ClaudePanel/ClaudePanel.tsx` around lines 40 - 45, Update the instruction handling in the submit flow around editSelection and the corresponding TextInput rendering so text entered while a request is loading is not cleared when the earlier request completes. Prefer disabling TextInput whenever isLoading is true, or capture and clear only the instruction submitted by that request, while preserving the existing successful-apply behavior.
🤖 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/editor/src/renderer/src/common/ClaudePanel/ClaudePanel.tsx`:
- Around line 16-20: Update the request lifecycle around beginEdit and applyEdit
to capture the target document identity, revision, and selected text when Claude
starts, then validate those values against the current editor state before
applying the result. Discard or safely rebase stale results when the document,
revision, or target text changes, preventing edits from being applied to another
document or shifted range.
---
Outside diff comments:
In `@apps/editor/src/renderer/src/common/ClaudePanel/ClaudePanel.tsx`:
- Around line 40-45: Update the instruction handling in the submit flow around
editSelection and the corresponding TextInput rendering so text entered while a
request is loading is not cleared when the earlier request completes. Prefer
disabling TextInput whenever isLoading is true, or capture and clear only the
instruction submitted by that request, while preserving the existing
successful-apply behavior.
🪄 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: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: ed87018b-936e-4c74-8c82-5f83451bf9d9
📒 Files selected for processing (10)
apps/editor/src/main/github/fetchGistFiles.test.tsapps/editor/src/main/github/fetchGistFiles.tsapps/editor/src/main/github/patchGist.tsapps/editor/src/main/github/toGistId.test.tsapps/editor/src/main/github/toGistId.tsapps/editor/src/main/ipc/gistHandlers.tsapps/editor/src/renderer/src/App.test.tsxapps/editor/src/renderer/src/App.tsxapps/editor/src/renderer/src/common/ClaudePanel/ClaudePanel.tsxapps/editor/src/renderer/src/hooks/useGistDraft.test.ts
🚧 Files skipped from review as they are similar to previous changes (7)
- apps/editor/src/main/github/fetchGistFiles.test.ts
- apps/editor/src/main/ipc/gistHandlers.ts
- apps/editor/src/main/github/patchGist.ts
- apps/editor/src/renderer/src/App.tsx
- apps/editor/src/renderer/src/App.test.tsx
- apps/editor/src/renderer/src/hooks/useGistDraft.test.ts
- apps/editor/src/main/github/fetchGistFiles.ts
…d on The request only remembered the range it asked about, so an answer was spliced into whatever was at those offsets when it came back. Typing above the selection, undoing, or opening another file while the request ran put the rewrite at the wrong place — or into a document Claude never saw. The text that was sent is now held with the range, and the answer is applied only while that text is still exactly there. Otherwise it is dropped and the panel says so, keeping the instruction for a second try.
Static analysis reads the rebuilt address string as carrying whatever the gist response held, and cannot see that the host was checked in another function. The protocol and host are now checked against allowed lists immediately before the fetch, and the parsed URL is passed to it rather than a string assembled from its path. Same requests and same rejections as before: only https on GitHub's own raw host, query and fragment dropped, redirects refused.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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/editor/src/renderer/src/common/ClaudePanel/ClaudePanel.tsx`:
- Around line 24-28: Track the document identity or revision when the Claude
request starts and validate it in App.applyEdit/onApply before applying
rewritten text, so switching to another document with identical content returns
false and preserves STALE_MESSAGE. If document navigation cannot be observed,
prevent document changes while the request is pending. Add an App.test.tsx
regression covering two documents with identical content.
🪄 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: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: b1f67160-9203-4ab7-b4bc-a4001d6a0d37
📒 Files selected for processing (6)
apps/editor/src/main/github/fetchGistFiles.test.tsapps/editor/src/main/github/fetchGistFiles.tsapps/editor/src/renderer/src/App.test.tsxapps/editor/src/renderer/src/App.tsxapps/editor/src/renderer/src/common/ClaudePanel/ClaudePanel.test.tsxapps/editor/src/renderer/src/common/ClaudePanel/ClaudePanel.tsx
🚧 Files skipped from review as they are similar to previous changes (4)
- apps/editor/src/renderer/src/common/ClaudePanel/ClaudePanel.test.tsx
- apps/editor/src/renderer/src/App.tsx
- apps/editor/src/renderer/src/App.test.tsx
- apps/editor/src/main/github/fetchGistFiles.test.ts
Holding the text that was sent catches an answer that no longer fits, but not one that fits a document it was never about: an empty document and a new one always hold the same thing, and two files can as well. Opening another one mid-request would take the rewrite instead. The document now counts the times it is replaced — a new document, a file opened, a gist file loaded — and an answer is applied only while that count is the one the request started on. Editing and saving stay on the same document, so neither moves it on.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/editor/src/renderer/src/App.tsx (2)
73-107: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winValidate the target position, not only its text.
current.slice(from, to) === textdoes not prove that the range still identifies Claude’s target. Forfoofoo, if Claude receives the secondfooat3..6and the user prependsbar, the old range still containsfoo, but it now selects the first occurrence. This code then replaces the wrong text.The simpler safe behavior is to reject every response after any content change. If edits after
tomust remain valid, capture the prefix throughstartand require it to match before splicing. Add a regression test for this repeated-text shift.Proposed fix
- const asked = useRef({ start, end, text: targetText, revision }) + const asked = useRef({ + start, + end, + prefix: content.slice(0, start), + text: targetText, + revision, + }) const beginEdit = () => { - asked.current = { start, end, text: targetText, revision } + asked.current = { + start, + end, + prefix: content.slice(0, start), + text: targetText, + revision, + } } - const { start: from, end: to, text, revision: asWas } = asked.current + const { start: from, end: to, prefix, text, revision: asWas } = asked.current + if (current.slice(0, from) !== prefix) return false if (current.slice(from, to) !== text) return falseAs per coding guidelines, run
pnpm test:coverageand verify 100% coverage on all touched files.🤖 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/editor/src/renderer/src/App.tsx` around lines 73 - 107, Update applyEdit to reject responses whenever the document content has changed, rather than relying only on current.slice(from, to) matching the requested text; use the captured target state in asked and live to validate the complete content before splicing. Preserve whole-document handling and selection updates for unchanged content, and add a regression test covering repeated text shifting to ensure the wrong occurrence is never replaced. Run pnpm test:coverage and verify 100% coverage for touched files.Source: Coding guidelines
36-37: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winReset undo history from document boundaries.
filePathdoes not cover New or Save As.newDocument()andsave(true)can replace the document whilefilePathis stillnull, souseUndoRedocan persist the previous undo stack under the same path. Depend onrevisionfor document replacements, and add coverage for undo history after New on an untitled document and after Save As.🤖 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/editor/src/renderer/src/App.tsx` around lines 36 - 37, Update the useEffect that calls reset() so its dependency tracks the document revision rather than only filePath, ensuring New and Save As document replacements clear the undo history even when the path remains null or unchanged. Add coverage for undo history reset after New on an untitled document and after Save As.
🤖 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/editor/src/renderer/src/hooks/useDocument.ts`:
- Around line 28-32: The asynchronous save flow must not apply results to a
replacement document. Use the existing revision state and add a synchronous
document-identity ref that advances before every replacement state update;
capture its value when save starts, and guard post-await error, dirty-state,
origin, and path updates in save so stale results are ignored while preserving
content comparison for edits to the same document. Add deferred file.save and
gists.stage tests covering replacement before completion, then run pnpm
test:coverage and ensure touched files remain fully covered.
---
Outside diff comments:
In `@apps/editor/src/renderer/src/App.tsx`:
- Around line 73-107: Update applyEdit to reject responses whenever the document
content has changed, rather than relying only on current.slice(from, to)
matching the requested text; use the captured target state in asked and live to
validate the complete content before splicing. Preserve whole-document handling
and selection updates for unchanged content, and add a regression test covering
repeated text shifting to ensure the wrong occurrence is never replaced. Run
pnpm test:coverage and verify 100% coverage for touched files.
- Around line 36-37: Update the useEffect that calls reset() so its dependency
tracks the document revision rather than only filePath, ensuring New and Save As
document replacements clear the undo history even when the path remains null or
unchanged. Add coverage for undo history reset after New on an untitled document
and after Save As.
🪄 Autofix
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: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 2cd73b71-1503-46e0-8c58-f6818e1c709c
📒 Files selected for processing (4)
apps/editor/src/renderer/src/App.test.tsxapps/editor/src/renderer/src/App.tsxapps/editor/src/renderer/src/hooks/useDocument.test.tsapps/editor/src/renderer/src/hooks/useDocument.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/editor/src/renderer/src/hooks/useDocument.test.ts
The merged coverage step ran the root `test:coverage`, which is recursive: every package, both workers and the editor app ran inside the web job, each already covered by a job of its own. The editor's jsdom tests shared the runner with the browser and storybook tiers and timed out at five seconds — two of them failed there while passing everywhere else. The step now runs the web app alone. Each app's suites are addressable on their own for it: the web app's merged pass, and the editor's unit and e2e tiers with their coverage and per-file gate. The editor has no CI job yet, so its tests run nowhere in CI until it gets one; it is still linted and typechecked with the rest of the workspace.
A save reads the path, origin and content it is about, then waits on the main process. The document can be replaced while it waits, and what came back was applied to whatever was there: a write finishing after a gist file was opened handed that file the old path and dropped its origin, so the next save wrote gist content to disk and there was nothing left to publish. A failure did the same with its message. The count of replacements is now a ref, advanced as the replacement is made rather than through state, which a resolving promise can outrun. A save holds the count it started on and stops before touching anything when it no longer matches, and an answer from Claude is held to the same test.
|



The first half of
apps/editor: a desktop markdown editor that edits through the local Claude CodeCLI and keeps gist work in a local sandbox. Split out of one oversized branch — streaming, cancel,
writing from an existing gist, and the rest of the sandbox work follow in a second PR.
Closes #296, #297, #298, #299, #300, #320, #321 — all under Epic #295.
What it does
The document. Electron + electron-vite + React 19, rendered through
@soroush.tech/markdownandthemed by
@soroush.tech/design-system. Native New/Open/Save/Save As, unsaved-change tracking with aprompt on close, coalesced undo/redo, and edit / preview / split / live-edit modes.
Editing with Claude. Select text — or nothing, for the whole document — describe the change, and
the local
claudeCLI rewrites it in place.Gists. Connect an account with a fine-grained PAT, encrypted at rest through Electron's
safeStorage. Browse your gists, open their files, and edit against a local sandbox: adds,deletes, edits and the description are all staged, and only Publish sends them — as one request.
Drafts persist to disk, so unfinished work survives quitting.
Notable decisions
contextIsolation+sandbox+ nonodeIntegration,invoke/handleIPC only behind a typedcontextBridge, every responseResult-wrapped, IPCarguments validated, CSP set through response headers. The gist token never crosses back to the
renderer, and avatars are inlined as
data:URIs rather than wideningimg-src.disabled, so no repo's
CLAUDE.md/hooks/MCP config bleeds into a request and nothinguser-controlled reaches the shell. Deliberately not
--bare: reusing the signed-in login is the point."Discard changes" — keeping the work costs nothing, so there is no cancel, and Escape keeps it.
Review findings, and what changed for them
SonarQube raised 12 and CodeRabbit 16. All are addressed; the ones that changed behaviour rather
than shape:
file:saveused to accept any path the renderer sent.It now writes only to a path a dialog in this process handed out.
in the drafts file; a
raw_urlis followed only when it is GitHub's own host.content, which — edited and published — would have cut the gist down to it. The read fails instead.
and the later write dropped the earlier one; an interrupted write truncated the file, losing every
gist's staged work silently. Read-modify-write now happens as a single queued step through a
temporary file and a rename.
is when it arrives, and a draft change announced for another gist no longer blanks the open one.
anywhere, wiping what had been typed into the Claude instruction.
closes, and neither startup nor the close prompt can end the process as an unhandled rejection.
Verification
observable instead
pnpm lint,pnpm typecheckandpnpm buildcleanKnown gaps
fetchin main.SidebarItem.iconin the design system takes only anIconName, so the rail's rows are hand-rolledPressables and the GitHub glyph is a local SVG. Worth its own task.Summary by CodeRabbit
New Features
Tests