Skip to content
Open
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
75 changes: 74 additions & 1 deletion packages/electron-app/electron/main/ipc.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, shell, type OpenDialogOptions } from "electron"
import fs from "fs"
import { requestMicrophoneAccess } from "./permissions"
import type { CliProcessManager, CliStatus } from "./process-manager"
import { openWorkspaceTarget, type WorkspaceEditor, type WorkspaceOpenTarget } from "./workspace-open"
import { setWorkspaceMenuEnabled } from "./menu"

let wakeLockId: number | null = null

Expand All @@ -18,6 +20,34 @@ interface DialogOpenResult {
paths: string[]
}

async function resolveLocalWorkspaceFolder(
mainWindow: BrowserWindow,
cliManager: CliProcessManager,
instanceId: string,
worktreeSlug: string,
): Promise<string> {
const baseUrl = cliManager.getStatus().url
if (!baseUrl) throw new Error("Local CodeNomad server is unavailable")
const cookieName = cliManager.getAuthCookieName()
const cookie = (await mainWindow.webContents.session.cookies.get({ url: baseUrl, name: cookieName }))[0]
const headers = cookie ? { Cookie: `${cookie.name}=${cookie.value}` } : undefined
const workspaceResponse = await fetch(`${baseUrl.replace(/\/$/, "")}/api/workspaces/${encodeURIComponent(instanceId)}`, { headers })
if (!workspaceResponse.ok) throw new Error("Workspace is not active")
const workspace = await workspaceResponse.json() as { path?: unknown }
if (typeof workspace.path !== "string") throw new Error("Workspace path is unavailable")
if (worktreeSlug === "root") return workspace.path

const worktreeResponse = await fetch(
`${baseUrl.replace(/\/$/, "")}/api/workspaces/${encodeURIComponent(instanceId)}/worktrees`,
{ headers },
)
if (!worktreeResponse.ok) throw new Error("Workspace worktrees are unavailable")
const payload = await worktreeResponse.json() as { worktrees?: Array<{ slug?: unknown; directory?: unknown }> }
const worktree = payload.worktrees?.find((candidate) => candidate.slug === worktreeSlug)
if (!worktree || typeof worktree.directory !== "string") throw new Error("Selected worktree is unavailable")
return worktree.directory
}

export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
cliManager.on("status", (status: CliStatus) => {
if (!mainWindow.isDestroyed()) {
Expand Down Expand Up @@ -88,6 +118,49 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
return directories
})

ipcMain.handle(
"workspace:openTarget",
async (event, payload: { target?: unknown; instanceId?: unknown; worktreeSlug?: unknown; path?: unknown; editor?: unknown }): Promise<{ ok: true }> => {
if (mainWindow.isDestroyed() || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame) {
throw new Error("Workspace open requests are limited to the local main window")
}
const localUrl = cliManager.getStatus().url
if (!localUrl || new URL(event.senderFrame.url).origin !== new URL(localUrl).origin) {
throw new Error("Workspace open requests require the local CodeNomad origin")
}
const target = payload?.target
const instanceId = payload?.instanceId
const worktreeSlug = payload?.worktreeSlug
const editor = payload?.editor
if (
(target !== "default" && target !== "reveal" && target !== "terminal" && target !== "editor")
|| typeof instanceId !== "string"
|| typeof worktreeSlug !== "string"
|| (payload.path !== undefined && typeof payload.path !== "string")
|| (editor !== undefined && editor !== "vscode" && editor !== "cursor" && editor !== "zed" && editor !== "vscodium")
) {
throw new Error("Invalid workspace open request")
}
const workspaceFolder = await resolveLocalWorkspaceFolder(mainWindow, cliManager, instanceId, worktreeSlug)
await openWorkspaceTarget(
target as WorkspaceOpenTarget,
workspaceFolder,
payload.path as string | undefined,
editor as WorkspaceEditor | undefined,
{ openPath: (path) => shell.openPath(path), revealPath: (path) => shell.showItemInFolder(path) },
)
return { ok: true }
},
)

ipcMain.handle("workspace:setMenuEnabled", (event, enabled: unknown): { ok: true } => {
if (mainWindow.isDestroyed() || event.sender !== mainWindow.webContents || event.senderFrame !== mainWindow.webContents.mainFrame) {
throw new Error("Workspace menu updates are limited to the local main window")
}
setWorkspaceMenuEnabled(enabled === true)
return { ok: true }
})

ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
const next = Boolean(enabled)
if (next) {
Expand Down
50 changes: 46 additions & 4 deletions packages/electron-app/electron/main/menu.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,34 @@
import { Menu, BrowserWindow, MenuItemConstructorOptions } from "electron"
import { app, Menu, BrowserWindow, MenuItemConstructorOptions } from "electron"

interface ApplicationMenuActions {
reload(): void
forceReload(): void
}

let workspaceActionsRequested = false
let applicationMenu: Menu | null = null
let localMainWindow: BrowserWindow | null = null

function updateWorkspaceMenuState() {
const enabled = workspaceActionsRequested && BrowserWindow.getFocusedWindow() === localMainWindow
for (const id of ["open-workspace-folder", "open-workspace-terminal", "open-workspace-editor"]) {
const item = applicationMenu?.getMenuItemById(id)
if (item) item.enabled = enabled
}
}

export function setWorkspaceMenuEnabled(enabled: boolean) {
workspaceActionsRequested = enabled
updateWorkspaceMenuState()
}

export function createApplicationMenu(mainWindow: BrowserWindow, actions: ApplicationMenuActions) {
localMainWindow = mainWindow
const isMac = process.platform === "darwin"
const sendCommand = (id: string) => () => {
if (id.startsWith("open-workspace-") && BrowserWindow.getFocusedWindow() !== mainWindow) return
mainWindow.webContents.send("menu:action", id)
}

const template: MenuItemConstructorOptions[] = [
...(isMac
Expand All @@ -31,9 +53,20 @@ export function createApplicationMenu(mainWindow: BrowserWindow, actions: Applic
{
label: "New Instance",
accelerator: "CmdOrCtrl+N",
click: () => {
mainWindow.webContents.send("menu:newInstance")
},
click: sendCommand("new-instance"),
},
{ type: "separator" as const },
{ id: "open-workspace-folder", label: "Open Project Folder", click: sendCommand("open-workspace-folder") },
{ id: "open-workspace-terminal", label: "Open Terminal Here", click: sendCommand("open-workspace-terminal") },
{
id: "open-workspace-editor",
label: "Open Project In",
submenu: [
{ label: "VS Code", click: sendCommand("open-workspace-editor-vscode") },
{ label: "Cursor", click: sendCommand("open-workspace-editor-cursor") },
{ label: "Zed", click: sendCommand("open-workspace-editor-zed") },
{ label: "VSCodium", click: sendCommand("open-workspace-editor-vscodium") },
],
},
{ type: "separator" as const },
isMac ? { role: "close" as const } : { role: "quit" as const },
Expand Down Expand Up @@ -85,5 +118,14 @@ export function createApplicationMenu(mainWindow: BrowserWindow, actions: Applic
]

const menu = Menu.buildFromTemplate(template)
applicationMenu = menu
Menu.setApplicationMenu(menu)
updateWorkspaceMenuState()
mainWindow.webContents.on("did-start-navigation", (_event, _url, _isInPlace, isMainFrame) => {
if (!isMainFrame) return
workspaceActionsRequested = false
updateWorkspaceMenuState()
})
app.on("browser-window-focus", updateWorkspaceMenuState)
app.on("browser-window-blur", updateWorkspaceMenuState)
}
80 changes: 80 additions & 0 deletions packages/electron-app/electron/main/workspace-open.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import assert from "node:assert/strict"
import { EventEmitter } from "node:events"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import test from "node:test"
import type { ChildProcess } from "node:child_process"
import { defaultOpenMode, editorCandidates, openWorkspaceTarget } from "./workspace-open"

test("selects only the requested editor", () => {
assert.deepEqual(editorCandidates("zed", "linux", {}), [{ command: "zed", verifyStart: true }])
assert.deepEqual(editorCandidates("vscodium", "darwin", {}), [
{ command: "/usr/bin/open", args: ["-a", "VSCodium"], waitForExit: true },
])
})

test("opens only paths inside the workspace", async () => {
const root = mkdtempSync(join(tmpdir(), "codenomad-workspace-"))
const outside = mkdtempSync(join(tmpdir(), "codenomad-outside-"))
const opened: string[] = []
const dependencies = {
openPath: async (path: string) => { opened.push(path); return "" },
revealPath: () => undefined,
}

try {
await openWorkspaceTarget("default", root, ".", undefined, dependencies)
assert.deepEqual(opened, [root])
await assert.rejects(openWorkspaceTarget("default", root, outside, undefined, dependencies), /outside the workspace/)
} finally {
rmSync(root, { recursive: true, force: true })
rmSync(outside, { recursive: true, force: true })
}
})

test("edits Windows scripts and rejects executable default-open targets", () => {
const root = mkdtempSync(join(tmpdir(), "codenomad-executable-"))
const script = join(root, "run.cmd")
writeFileSync(script, "echo unsafe")
try {
assert.equal(defaultOpenMode(script, "win32"), "edit")
const document = join(root, "notes.txt")
writeFileSync(document, "safe")
assert.equal(defaultOpenMode(document, "win32"), "open")
const executable = join(root, "run.exe")
writeFileSync(executable, "unsafe")
assert.equal(defaultOpenMode(executable, "win32"), "choose")
} finally {
rmSync(root, { recursive: true, force: true })
}
})

test("launches terminals in the selected directory without a shell", async () => {
const root = mkdtempSync(join(tmpdir(), "codenomad-terminal-"))
const nested = join(root, "nested")
mkdirSync(nested)
const launches: Array<{ command: string; args: readonly string[]; cwd: string }> = []
const spawnProcess = (command: string, args: readonly string[], options: { cwd: string }) => {
launches.push({ command, args, cwd: options.cwd })
const child = new EventEmitter() as ChildProcess
child.unref = () => child
queueMicrotask(() => {
child.emit("spawn")
child.emit("exit", 0)
})
return child
}

try {
await openWorkspaceTarget("terminal", root, "nested", undefined, {
openPath: async () => "",
revealPath: () => undefined,
spawnProcess,
})
assert.equal(launches[0]?.cwd, nested)
assert.equal(launches[0]?.args.includes(nested), false)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
Loading
Loading