Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
605 changes: 310 additions & 295 deletions bun.lock

Large diffs are not rendered by default.

36 changes: 11 additions & 25 deletions electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,10 @@ function matchGlob(pattern, filePath) {
}

const { applyMenu } = require('./menu.cjs')
const { parseFolder, parseFiles } = require('./parser-service.cjs')
const { parseFolder, disposeParser } = require('./parser-service.cjs')
const projectState = require('./project-state.cjs')
const { watchFolder, buildIgnoreSet } = require('./watcher.cjs')

const INCREMENTAL_FILE_THRESHOLD = 25

let _gitCache = null
function getGit() {
if (!currentFolder) throw new Error('No project folder is open')
Expand Down Expand Up @@ -118,14 +116,6 @@ async function resolveSafePath(file) {
return absoluteFile
}

function detectLineEnding(text) {
return text.includes('\r\n') ? '\r\n' : '\n'
}

function splitLines(text) {
return text.split(/\r\n|\n/)
}

app.setName('Graphy')
app.setAppUserModelId('com.ntgrm.graphy')

Expand Down Expand Up @@ -290,40 +280,32 @@ async function openFolder(folder) {
stopWatcher()
stopWatcher = null
}
stopWatcher = watchFolder(resolved, (changedFiles) => {
stopWatcher = watchFolder(resolved, () => {
if (currentFolder === resolved) {
void runParse(resolved, changedFiles)
void runParse(resolved)
}
})

await runParse(resolved)
}

async function runParse(folder, changedFiles = null) {
async function runParse(folder) {
const generation = ++parseGeneration
parseError = null
parseInFlight = folder
broadcastGraph()

const useIncremental =
Array.isArray(changedFiles) &&
changedFiles.length > 0 &&
changedFiles.length <= INCREMENTAL_FILE_THRESHOLD &&
currentGraph !== null

try {
const graph = useIncremental
? await parseFiles(app, folder, changedFiles, currentGraph)
: await parseFolder(app, folder)
const graph = await parseFolder(app, folder)
if (generation !== parseGeneration) return
currentGraph = graph
if (!useIncremental) currentLayout = null
currentLayout = null
parseError = null
projectState.writeCache(folder, graph, currentLayout)
} catch (err) {
if (generation !== parseGeneration) return
parseError = err instanceof Error ? err.message : String(err)
if (!useIncremental) currentGraph = null
currentGraph = null
} finally {
if (generation === parseGeneration) {
parseInFlight = null
Expand Down Expand Up @@ -1318,3 +1300,7 @@ app.on('window-all-closed', () => {
app.quit()
}
})

app.on('before-quit', () => {
disposeParser()
})
146 changes: 90 additions & 56 deletions electron/parser-service.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ const path = require('node:path')

const PARSER_TIMEOUT_MS = 5 * 60 * 1000

let child = null
let childBundleMtime = 0
let nextRequestId = 0
const pending = new Map()

function resolveParserBundle(app) {
const candidates = [
path.join(app.getAppPath(), 'dist-electron', 'parser.cjs'),
Expand All @@ -23,70 +28,99 @@ function resolveParserBundle(app) {
return found
}

function runParser(app, folder, { incremental = false, input = null } = {}) {
return new Promise((resolve, reject) => {
const bundlePath = resolveParserBundle(app)
const args = incremental ? [folder, '--incremental'] : [folder]
const child = fork(bundlePath, args, {
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
})

let settled = false
const finish = (fn) => {
if (settled) return
settled = true
clearTimeout(timer)
child.removeAllListeners()
fn()
}
function rejectAllPending(err) {
for (const entry of pending.values()) {
clearTimeout(entry.timer)
entry.reject(err)
}
pending.clear()
}

const timer = setTimeout(() => {
child.kill('SIGKILL')
finish(() =>
reject(new Error(`Parser timed out after ${PARSER_TIMEOUT_MS}ms`)),
)
}, PARSER_TIMEOUT_MS)
function killChild() {
if (!child) return
const existing = child
child = null
existing.removeAllListeners()
try {
existing.kill('SIGKILL')
} catch {
// ignore — process may already be gone
}
}

function ensureChild(app) {
const bundlePath = resolveParserBundle(app)
const mtime = fs.statSync(bundlePath).mtimeMs

// Dev: when the parser bundle is rebuilt, recycle the worker so
// the next request runs the freshly built code.
if (child && mtime !== childBundleMtime) {
rejectAllPending(new Error('Parser bundle changed; restarting worker'))
killChild()
}

if (child) return child

childBundleMtime = mtime
child = fork(bundlePath, [], {
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
})

child.on('message', (msg) => {
if (!msg || typeof msg !== 'object') return
if (msg.type === 'graph') {
finish(() => resolve(msg.graph))
} else if (msg.type === 'error') {
finish(() => reject(new Error(msg.message)))
}
})

child.on('error', (err) => {
finish(() => reject(err))
})

child.on('exit', (code, signal) => {
if (settled) return
finish(() =>
reject(
new Error(
`Parser exited unexpectedly (code=${code}, signal=${signal})`,
),
),
)
})

if (incremental && input) {
child.send({ type: 'input', ...input })
child.on('message', (msg) => {
if (!msg || typeof msg !== 'object' || typeof msg.id !== 'number') return
const entry = pending.get(msg.id)
if (!entry) return
pending.delete(msg.id)
clearTimeout(entry.timer)
if (msg.type === 'result') {
entry.resolve(msg.graph)
} else if (msg.type === 'error') {
entry.reject(new Error(msg.message))
}
})

child.on('error', (err) => {
rejectAllPending(err)
})

child.on('exit', (code, signal) => {
child = null
rejectAllPending(
new Error(`Parser exited unexpectedly (code=${code}, signal=${signal})`),
)
})

return child
}

function parseFolder(app, folder) {
return runParser(app, folder)
}
return new Promise((resolve, reject) => {
let worker
try {
worker = ensureChild(app)
} catch (err) {
reject(err)
return
}

function parseFiles(app, folder, changedFiles, previousGraph) {
return runParser(app, folder, {
incremental: true,
input: { changedFiles, previousGraph },
const id = ++nextRequestId
const timer = setTimeout(() => {
pending.delete(id)
// The worker may be wedged in a long parse; force a respawn so
// the next request gets a clean state.
killChild()
reject(new Error(`Parser timed out after ${PARSER_TIMEOUT_MS}ms`))
}, PARSER_TIMEOUT_MS)

pending.set(id, { resolve, reject, timer })
worker.send({ type: 'parse', id, folder })
})
}

module.exports = { parseFolder, parseFiles }
function disposeParser() {
rejectAllPending(new Error('Parser disposed'))
killChild()
}

module.exports = { parseFolder, disposeParser }
3 changes: 0 additions & 3 deletions electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,6 @@ contextBridge.exposeInMainWorld('graphyDesktop', {
ipcRenderer.invoke('summary:delete', { nodeId }),
onProject: (handler) => subscribe('project:set', handler),
onGraph: (handler) => subscribe('graph:set', handler),
readFunctionSource: (payload) => ipcRenderer.invoke('function:read', payload),
writeFunctionSource: (payload) =>
ipcRenderer.invoke('function:write', payload),
getFileTree: () => ipcRenderer.invoke('graphy:file-tree'),
createFile: (filePath) => ipcRenderer.invoke('graphy:create-file', filePath),
createDir: (dirPath) => ipcRenderer.invoke('graphy:create-dir', dirPath),
Expand Down
33 changes: 12 additions & 21 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
}
},
"dependencies": {
"@anthropic-ai/sdk": "^0.96.0",
"@anthropic-ai/sdk": "^0.97.0",
"@base-ui/react": "^1.4.1",
"@codemirror/autocomplete": "^6.20.2",
"@codemirror/commands": "^6.10.3",
Expand All @@ -86,8 +86,7 @@
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.0",
"@fontsource-variable/geist": "^5.2.9",
"@openai/codex-sdk": "^0.130.0",
"@tabler/icons-react": "^3.44.0",
"@openai/codex-sdk": "^0.131.0",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-devtools": "^0.10.5",
"@tanstack/react-router": "^1.170.4",
Expand All @@ -99,50 +98,42 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"date-fns": "^4.2.1",
"elkjs": "^0.11.1",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^1.16.0",
"next-themes": "^0.4.6",
"nitro": "npm:nitro-nightly@^3.0.1-20260512-093145-0498ce70",
"nitro": "npm:nitro-nightly@^3.0.1-20260518-130639-31265391",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.6",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.11.1",
"recharts": "3.8.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.7.0",
"simple-git": "^3.36.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"ts-morph": "^28.0.0",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2"
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@tanstack/devtools-vite": "^0.7.0",
"@tanstack/eslint-config": "^0.4.0",
"@types/node": "^25.8.0",
"@types/node": "^25.9.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"chokidar": "^5.0.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^42.1.0",
"electron-builder": "^25.1.8",
"eslint": "^9.20.0",
"jsdom": "^28.1.0",
"prettier": "^3.8.1",
"typescript": "^6.0.2",
"vite": "^8.0.0",
"vitest": "^4.1.5",
"electron-builder": "^26.8.1",
"eslint": "^10.4.0",
"jsdom": "^29.1.1",
"prettier": "^3.8.3",
"typescript": "^6.0.3",
"vite": "^8.0.13",
"vitest": "^4.1.6",
"wait-on": "^9.0.10"
},
"pnpm": {
Expand Down
2 changes: 1 addition & 1 deletion scripts/build-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ declare const Bun: {
}

const root = process.cwd()
const entry = path.join(root, 'src/modules/parser/parse-cli.ts')
const entry = path.join(root, 'src/modules/parser/parse-service.ts')
const outDir = path.join(root, 'dist-electron')
const outFile = 'parser.cjs'

Expand Down
Loading
Loading