Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
fe15791
docs: implementation plan for global editor full experience (#513)
Juliusolsson05 Jul 9, 2026
0ef39b2
fix(editor): map react language ids to monaco ids — unblank .tsx/.jsx…
Juliusolsson05 Jul 9, 2026
45b22cd
fix(editor): shared monaco theme ownership state; enable semantic hig…
Juliusolsson05 Jul 9, 2026
7ab38d9
fix(editor): eager MonacoEnvironment install + worker-src CSP (#513)
Juliusolsson05 Jul 9, 2026
3393342
fix(editor): working tab close — confirm dialog, force-close, non-des…
Juliusolsson05 Jul 9, 2026
1260da0
feat(editor): ref-counted model registry — undo history survives tab …
Juliusolsson05 Jul 9, 2026
06d06e2
feat(lsp): pluggable server registry + hover/definition/completion/re…
Juliusolsson05 Jul 9, 2026
1a50c9f
feat(editor): LSP in the real editor — diagnostics, hover, go-to-def,…
Juliusolsson05 Jul 9, 2026
ad23e66
feat(editor): file watcher — open buffers follow external writes; qui…
Juliusolsson05 Jul 9, 2026
4f46d2e
feat(editor): explorer context menu — create/rename/delete + showHidd…
Juliusolsson05 Jul 9, 2026
ba5a218
feat(editor): quick open (cmd+P), content search (cmd+shift+F), fulls…
Juliusolsson05 Jul 9, 2026
bcd2738
feat(editor): persist open-tab paths + geometry across restarts — con…
Juliusolsson05 Jul 9, 2026
694b2cf
refactor(editor): editor-owned command module + quick-open/search/ful…
Juliusolsson05 Jul 9, 2026
46c5060
refactor(editor): shared bufferOps for global-editor + ai-workspace; …
Juliusolsson05 Jul 9, 2026
3ecb4af
docs: execution notes on the editor plan — deviations + verification …
Juliusolsson05 Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,221 changes: 2,221 additions & 0 deletions docs/superpowers/plans/2026-07-09-global-editor-full-experience.md

Large diffs are not rendered by default.

142 changes: 140 additions & 2 deletions src/main/ipc/editorFs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ipcMain } from 'electron'
import { constants } from 'fs'
import { constants, type Dirent } from 'fs'
import { access, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises'
import { basename, dirname, join, relative, resolve } from 'path'

Expand All @@ -13,6 +13,9 @@ import type {
EditorFsReadResult,
EditorFsWriteResult,
EditorFsMutationResult,
EditorFsRecursiveListResult,
EditorFsSearchMatch,
EditorFsSearchResult,
} from '@shared/types/editorFs.js'

// WHY a hardcoded ignore list lives in main rather than the renderer:
Expand Down Expand Up @@ -90,7 +93,11 @@ function normalizeRelativePath(path: string): string {
return path.replace(/\\/g, '/').replace(/^\/+/, '')
}

function resolveInsideRoot(root: string, path = ''): string {
// Exported for editorFsWatch.ts — the containment invariant must hold on
// EVERY editor-fs surface, including watch registration (a watch is
// read-signal only, but "renderer paths can never address outside the
// project root" is only a real invariant if there are zero exceptions).
export function resolveInsideRoot(root: string, path = ''): string {
const rootAbs = resolve(root)
const target = resolve(rootAbs, normalizeRelativePath(path))
const rel = relative(rootAbs, target)
Expand Down Expand Up @@ -312,6 +319,137 @@ export function registerEditorFsIpc(): void {
},
)

ipcMain.handle(
'editor-fs:list-files-recursive',
async (_evt, params: { root: string }): Promise<EditorFsRecursiveListResult> => {
try {
const root = resolve(params.root)
const rootStat = await stat(root)
if (!rootStat.isDirectory()) return { ok: false, error: 'not a directory' }
const files: string[] = []
// Hard cap. Quick-open ranks client-side over the whole list; 20k
// relative paths ≈ a few MB of IPC — fine once, not fine
// unbounded (a rogue root near / would otherwise walk the disk).
// `truncated` lets the UI say "index incomplete" instead of
// silently lying about coverage.
const LIMIT = 20_000
const walk = async (dirAbs: string): Promise<void> => {
if (files.length >= LIMIT) return
const dirents = await readdir(dirAbs, { withFileTypes: true }).catch(
(): Dirent[] => [],
)
for (const dirent of dirents) {
if (files.length >= LIMIT) return
// Same hygiene as list-directory: dotfiles and the junk list
// are invisible to quick-open. No showHidden variant on
// purpose — quick-open is for project sources, and a hidden
// file is one tree-toggle away in the explorer.
if (dirent.name.startsWith('.')) continue
if (dirent.isDirectory()) {
if (EDITOR_IGNORED_DIR_NAMES.has(dirent.name)) continue
await walk(join(dirAbs, dirent.name))
} else {
if (EDITOR_IGNORED_FILE_NAMES.has(dirent.name)) continue
files.push(toProjectPath(root, join(dirAbs, dirent.name)))
}
}
}
await walk(root)
return { ok: true, files, truncated: files.length >= LIMIT }
} catch (err) {
return { ok: false, error: errorMessage(err) }
}
},
)

ipcMain.handle(
'editor-fs:search-content',
async (
_evt,
params: { root: string; query: string; caseSensitive?: boolean },
): Promise<EditorFsSearchResult> => {
try {
const root = resolve(params.root)
const query = params.query
// Sub-2-char queries match nearly every line of every file —
// that's a full-disk read producing noise. Return empty instead
// of burning IO on it.
if (query.length < 2) {
return { ok: true, matches: [], truncated: false, filesScanned: 0 }
}
const caseSensitive = params.caseSensitive === true
const needle = caseSensitive ? query : query.toLowerCase()
const matches: EditorFsSearchMatch[] = []
let filesScanned = 0
let truncated = false
// Bounds. A JS scan of a typical repo (a few thousand files after
// the junk filter) lands well under a second; the caps are the
// fuse for pathological roots. If real projects hit these limits
// routinely, the follow-up is a ripgrep binary via the
// third_party manifest pattern (#119/#120), NOT raising the caps.
const MAX_MATCHES = 2_000
const MAX_FILE_BYTES = 1_048_576 // >1MB = generated bundles, lockfiles
const MAX_FILES = 20_000
const scanFile = async (abs: string): Promise<void> => {
const itemStat = await stat(abs).catch(() => null)
if (!itemStat || !itemStat.isFile() || itemStat.size > MAX_FILE_BYTES) return
const text = await readFile(abs, 'utf8').catch(() => null)
// NUL byte = binary; utf8-decoding it would produce garbage
// matches and garbage previews.
if (text === null || text.includes('\u0000')) return
const haystackFull = caseSensitive ? text : text.toLowerCase()
if (!haystackFull.includes(needle)) return
const lines = text.split('\n')
for (let i = 0; i < lines.length; i++) {
const hay = caseSensitive ? lines[i] : lines[i].toLowerCase()
const col = hay.indexOf(needle)
if (col === -1) continue
matches.push({
path: toProjectPath(root, abs),
line: i + 1,
column: col + 1,
preview:
lines[i].length > 200
? lines[i].slice(Math.max(0, col - 80), col + 120)
: lines[i],
})
if (matches.length >= MAX_MATCHES) {
truncated = true
return
}
}
}
const walk = async (dirAbs: string): Promise<void> => {
if (truncated) return
const dirents = await readdir(dirAbs, { withFileTypes: true }).catch(
(): Dirent[] => [],
)
for (const dirent of dirents) {
if (truncated) return
if (dirent.name.startsWith('.')) continue
const abs = join(dirAbs, dirent.name)
if (dirent.isDirectory()) {
if (EDITOR_IGNORED_DIR_NAMES.has(dirent.name)) continue
await walk(abs)
continue
}
if (EDITOR_IGNORED_FILE_NAMES.has(dirent.name)) continue
if (filesScanned >= MAX_FILES) {
truncated = true
return
}
filesScanned += 1
await scanFile(abs)
}
}
await walk(root)
return { ok: true, matches, truncated, filesScanned }
} catch (err) {
return { ok: false, error: errorMessage(err) }
}
},
)

ipcMain.handle(
'editor-fs:delete',
async (_evt, params: { root: string; path: string }): Promise<EditorFsMutationResult> => {
Expand Down
97 changes: 97 additions & 0 deletions src/main/ipc/editorFsWatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { ipcMain, type WebContents } from 'electron'
import { watch, type FSWatcher } from 'chokidar'
import { stat } from 'fs/promises'

import { resolveInsideRoot } from './editorFs.js'
import type { EditorFsChangeEvent } from '@shared/types/editorFs.js'

// Per-file watchers for open editor buffers (#513).
//
// WHY individual file watchers, not one recursive root watcher: the roots
// here are entire project checkouts (node_modules included). A recursive
// watcher on a big repo costs thousands of kernel watch descriptors and a
// storm of events the editor doesn't care about; the editor only needs to
// know about files it actually has open — a handful. Watch registration
// follows buffer lifetime, driven by the renderer (GlobalEditorShell
// keeps the watched set aligned with the active cwd's open tabs).
//
// WHY refcounted: two surfaces (Global Editor per-cwd buffers now,
// possibly split views later) may watch the same file; the second unwatch
// must not kill the first watcher.
//
// WHY broadcast to subscriber WebContents instead of replying per-invoke:
// changes arrive long after the watch call returns; push is the only
// shape that fits. The subscriber set self-prunes on 'destroyed' so a
// closed window doesn't accumulate dead senders.

type WatchEntry = { watcher: FSWatcher; refs: number }
const watchers = new Map<string, WatchEntry>() // key: contained absolute path
const subscribers = new Set<WebContents>()

function broadcast(event: EditorFsChangeEvent): void {
for (const wc of subscribers) {
if (!wc.isDestroyed()) wc.send('editor-fs:file-changed', event)
}
}

export function registerEditorFsWatchIpc(): void {
ipcMain.handle(
'editor-fs:watch',
async (evt, params: { root: string; path: string }) => {
subscribers.add(evt.sender)
evt.sender.once('destroyed', () => subscribers.delete(evt.sender))
// Containment before anything touches the filesystem — same
// invariant as every other editor-fs handler.
const key = resolveInsideRoot(params.root, params.path)
const existing = watchers.get(key)
if (existing) {
existing.refs += 1
return
}
// awaitWriteFinish debounces the half-written states most tools
// produce (git checkout, agents streaming a Write tool call) so the
// editor sees one settled change, not three partial ones.
const watcher = watch(key, {
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 250, pollInterval: 50 },
})
watcher.on('change', () => {
void stat(key)
.then(s =>
broadcast({
root: params.root,
path: params.path,
kind: 'change',
mtimeMs: s.mtimeMs,
}),
)
.catch(() =>
broadcast({
root: params.root,
path: params.path,
kind: 'change',
mtimeMs: null,
}),
)
})
watcher.on('unlink', () => {
broadcast({ root: params.root, path: params.path, kind: 'unlink', mtimeMs: null })
})
watchers.set(key, { watcher, refs: 1 })
},
)

ipcMain.handle(
'editor-fs:unwatch',
async (_evt, params: { root: string; path: string }) => {
const key = resolveInsideRoot(params.root, params.path)
const entry = watchers.get(key)
if (!entry) return
entry.refs -= 1
if (entry.refs <= 0) {
watchers.delete(key)
await entry.watcher.close()
}
},
)
}
2 changes: 2 additions & 0 deletions src/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { registerDebugIpc } from '@main/ipc/debug.js'
import { registerGitIpc } from '@main/ipc/git.js'
import { registerPerformanceIpc } from '@main/ipc/performance.js'
import { registerEditorFsIpc } from '@main/ipc/editorFs.js'
import { registerEditorFsWatchIpc } from '@main/ipc/editorFsWatch.js'
import { registerSetupIpc } from '@main/ipc/setup.js'
import { registerWorktreeActivityIpc } from '@main/ipc/worktreeActivity.js'
import { registerDictationIpc } from '@main/ipc/dictation.js'
Expand Down Expand Up @@ -67,6 +68,7 @@ export function registerAllIpc(deps: IpcDeps): void {
registerPerformanceIpc(deps.manager)
installPerformanceIpcInstrumentation()
registerEditorFsIpc()
registerEditorFsWatchIpc()
registerSessionIpc(deps.manager, deps.pasteDebugJournals)
registerProviderIpc()
registerLspIpc(deps.lspManager)
Expand Down
37 changes: 35 additions & 2 deletions src/main/ipc/lsp.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { ipcMain } from 'electron'

import type { LspManager } from '@main/lspManager.js'
import type { LspPosition } from '@shared/types/lsp.js'

// LSP-backed code intelligence for Monaco code blocks.
// LSP-backed code intelligence for Monaco surfaces.
//
// The renderer's CodeBlock component opens a document per visible
// code block, requests semantic tokens for coloring, and keeps the
// LSP diagnostics wired so errors inline. All of that flows through
// LSP diagnostics wired so errors inline. The Global Editor's
// MonacoFileEditor additionally uses the hover/definition/completion/
// references/symbols request channels (#513). All of that flows through
// LspManager — this file is a pure IPC adapter.

export function registerLspIpc(lspManager: LspManager): void {
Expand Down Expand Up @@ -47,4 +50,34 @@ export function registerLspIpc(lspManager: LspManager): void {
ipcMain.handle('lsp:get-semantic-tokens', async (_evt, clientUri: string) => {
return await lspManager.getSemanticTokens(clientUri)
})

ipcMain.handle(
'lsp:get-hover',
async (_evt, clientUri: string, position: LspPosition) =>
await lspManager.getHover(clientUri, position),
)

ipcMain.handle(
'lsp:get-definition',
async (_evt, clientUri: string, position: LspPosition) =>
await lspManager.getDefinition(clientUri, position),
)

ipcMain.handle(
'lsp:get-completions',
async (_evt, clientUri: string, position: LspPosition) =>
await lspManager.getCompletions(clientUri, position),
)

ipcMain.handle(
'lsp:get-references',
async (_evt, clientUri: string, position: LspPosition) =>
await lspManager.getReferences(clientUri, position),
)

ipcMain.handle(
'lsp:get-document-symbols',
async (_evt, clientUri: string) =>
await lspManager.getDocumentSymbols(clientUri),
)
}
Loading