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
19 changes: 11 additions & 8 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,22 @@ import { registerHandlers as registerSearchHandlers } from './ipc/search'
import { registerHandlers as registerShellHandlers } from './ipc/shell'
import { registerAgentHookForwarding } from './ipc/agentHookEvents'
import { registerHandlers as registerGitMonitorHandlers } from './ipc/git-monitor'
import { registerHandlers as registerStoreHandlers, loadSettingsSyncFromDisk, getSettingSync, setSettingsFromMain } from './store'
import { registerUIStateHandlers } from './uiStateStore'
import { registerHandlers as registerStoreHandlers, loadSettingsSyncFromDisk, getSettingSync } from './store'
import { getSettingsFilePath } from './settingsFile'
import { getUIStateSync, loadUIStateSync, migrateLegacyLifecycleState, registerUIStateHandlers, setUIStateFromMain } from './uiStateStore'
import { registerProjectStateHandlers } from './projectWorkspaceStore'
import { registerHandlers as registerMenuHandlers } from './ipc/menu'
import { registerHandlers as registerNotificationHandlers } from './ipc/notifications'
import { registerSkillHandlers } from '../skills/main/ipcSkills'
import { registerWorkspaceHandlers } from './workspaceManager'
import { buildApplicationMenu, setNewMainWindowFn } from './menu'
import { initShellEnv, getShellEnv } from './shellEnv'
import { currentExclusionSet } from './ipc/filesystem'
import { initAutoUpdater } from './auto-updater'
import { initSentry, captureMainException, flushSentry } from './sentry'
import { initAnalytics, devSimulateUpdateFrom, hasRunBefore } from './analytics'
import { startPerfMonitor, getLatestSnapshot } from './perf/perfMonitor'
import { PERF_GET } from '../shared/ipc-channels'
import { TELEMETRY_NOTICE_VERSION } from '../shared/types'
import { FILE_EXCLUSIONS, TELEMETRY_NOTICE_VERSION } from '../shared/types'
import { installWebContentsSecurity } from './webSecurity'
import { installProxyAuthHandler } from './browserProxy'
import { installBundledSkill } from './installBundledSkill'
Expand Down Expand Up @@ -223,6 +223,8 @@ log.info('Cate v%s starting (electron %s, node %s, platform %s)', app.getVersion
// Load persisted settings synchronously so window-creation code paths can read
// them before the async electron-store finishes initializing.
loadSettingsSyncFromDisk()
loadUIStateSync()
migrateLegacyLifecycleState(getSettingsFilePath())

// Optional GPU-rasterization workaround (off by default). Under this app's GPU
// load — many live xterm WebGL contexts + the worktree-territory WebGL2 renderer
Expand All @@ -244,8 +246,8 @@ if (getSettingSync('disableGpuRasterization')) {
// user whose acknowledged notice version is below TELEMETRY_NOTICE_VERSION
// sees it once, updaters included.
if (hasRunBefore()) {
if (!getSettingSync('onboardingCompleted')) {
void setSettingsFromMain({ onboardingCompleted: true })
if (!getUIStateSync('onboardingCompleted')) {
setUIStateFromMain('onboardingCompleted', true)
}
}

Expand All @@ -254,7 +256,8 @@ if (hasRunBefore()) {
// drive. Mark both as already handled so e2e starts on a clean canvas. Runs
// before the renderer queries settings, so the dialogs never flash.
if (IS_E2E) {
void setSettingsFromMain({ telemetryNoticeAcknowledgedVersion: TELEMETRY_NOTICE_VERSION, onboardingCompleted: true })
setUIStateFromMain('telemetryNoticeAcknowledgedVersion', TELEMETRY_NOTICE_VERSION)
setUIStateFromMain('onboardingCompleted', true)
}

// Initialize Sentry as early as possible — before any IPC handlers or windows.
Expand Down Expand Up @@ -327,7 +330,7 @@ app.whenReady().then(async () => {
: undefined
runtimes.ensureLocalRuntime({
root: app.getPath('home'),
exclusions: [...currentExclusionSet()],
exclusions: FILE_EXCLUSIONS,
env: e2ePathPrefix
? { ...runtimeEnv, PATH: `${e2ePathPrefix}${path.delimiter}${runtimeEnv.PATH ?? ''}` }
: runtimeEnv,
Expand Down
35 changes: 3 additions & 32 deletions src/main/ipc/fileExclusions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import os from 'node:os'
import path from 'node:path'
import { beforeEach, afterEach, describe, expect, test, vi } from 'vitest'
import type { FileSearchResult, FileTreeNode } from '../../shared/types'
import { FILE_EXCLUSIONS } from '../../shared/types'

// Capture the handlers registered via ipcMain.handle so we can invoke them
// directly without a live Electron main process.
Expand All @@ -21,31 +22,21 @@ vi.mock('../windowRegistry', () => ({
sendToWindow: vi.fn(),
}))

// Controllable exclusion list. filesystem.ts reads this live on every call via
// getSettingSync('fileExclusions') — mutating it between calls models a user
// editing the setting at runtime (the PR's "no relaunch" guarantee).
let exclusions: string[] = []
vi.mock('../store', () => ({
getSettingSync: (key: string) => (key === 'fileExclusions' ? exclusions : undefined),
}))

const { registerHandlers } = await import('./filesystem')
const { addAllowedRoot, removeAllowedRoot } = await import('./pathValidation')
const { FS_READ_DIR, FS_SEARCH } = await import('../../shared/ipc-channels')
const { registerTestDaemonRuntime } = await import('../runtime/testHarness')

registerHandlers()
const testRuntime = registerTestDaemonRuntime()
registerTestDaemonRuntime(FILE_EXCLUSIONS)
const readDirHandler = handlers.get(FS_READ_DIR)!
const searchHandler = handlers.get(FS_SEARCH)!
const fakeEvent = { sender: {} } as unknown

const readDir = async (p: string): Promise<FileTreeNode[]> => {
await testRuntime.setExclusions(exclusions)
return readDirHandler(fakeEvent, p, 'local') as Promise<FileTreeNode[]>
}
const search = async (root: string, q: string): Promise<FileSearchResult[]> => {
await testRuntime.setExclusions(exclusions)
return searchHandler(fakeEvent, root, q, undefined, 'local') as Promise<FileSearchResult[]>
}
const names = (nodes: FileTreeNode[]) => nodes.map((n) => n.name).sort()
Expand All @@ -55,7 +46,6 @@ describe('file exclusions across explorer + search', () => {
let root: string

beforeEach(async () => {
exclusions = []
// realpath so the registered allowed root matches validatePathStrict's
// symlink-resolved comparison (e.g. /tmp → /private/tmp on macOS).
root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'cate-excl-')))
Expand All @@ -79,41 +69,22 @@ describe('file exclusions across explorer + search', () => {
await fs.rm(root, { recursive: true, force: true })
})

test('empty exclusion list shows everything', async () => {
exclusions = []
expect(names(await readDir(root))).toEqual(['keep.txt', 'node_modules', 'src'])
})

test('readDir hides an excluded folder by exact name', async () => {
exclusions = ['node_modules']
test('readDir hides an internally excluded folder by exact name', async () => {
expect(names(await readDir(root))).toEqual(['keep.txt', 'src'])
})

test('readDir hides a same-named file at a nested level (exact-name, any depth)', async () => {
exclusions = ['node_modules']
// The folder-vs-file distinction does not matter: a file named like an
// exclusion is dropped too, matching how the watcher now ignores both
// `**/<name>` and `**/<name>/**`.
expect(names(await readDir(path.join(root, 'src')))).toEqual(['app.txt'])
})

test('search skips excluded folders and same-named files', async () => {
exclusions = ['node_modules']
// Name-only search: 'txt' matches keep.txt and src/app.txt by name.
const found = relPaths(await search(root, 'txt'))
expect(found).toEqual(['keep.txt', 'src/app.txt'])
// Nothing under node_modules/, and not the src/node_modules file either.
expect(found.some((p) => p.includes('node_modules'))).toBe(false)
})

test('exclusions are read live: editing the list takes effect on the next call', async () => {
exclusions = []
expect(names(await readDir(root))).toContain('node_modules')
expect(relPaths(await search(root, 'txt'))).toContain('node_modules/pkg.txt')

// User edits the setting at runtime — no relaunch.
exclusions = ['node_modules']
expect(names(await readDir(root))).not.toContain('node_modules')
expect(relPaths(await search(root, 'txt')).some((p) => p.includes('node_modules'))).toBe(false)
})
})
18 changes: 6 additions & 12 deletions src/main/ipc/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,9 @@ import {
FS_SEARCH,
FS_READ_BINARY,
} from '../../shared/ipc-channels'
import { FileTreeNode, FileSearchResult, FileSearchOptions } from '../../shared/types'
import { FILE_EXCLUSIONS, FileTreeNode, FileSearchResult, FileSearchOptions } from '../../shared/types'
import { broadcastToAll, sendToWindow, windowFromEvent } from '../windowRegistry'
import { getSettingSync } from '../store'

// Read the user-configured exclusion list live so changes take effect without
// a relaunch. Built into a Set per call for fast membership checks.
export function currentExclusionSet(): Set<string> {
return new Set(getSettingSync('fileExclusions'))
}
const exclusionSet = new Set(FILE_EXCLUSIONS)

/** Trailing-edge debounce window for coalescing watcher bursts. */
const DISPATCH_DEBOUNCE_MS = 16
Expand All @@ -65,8 +59,8 @@ function watcherKey(windowId: number, dirPath: string, scopeId?: string): string
// Leaf filesystem operations live in the electron-free capability module
// (src/runtime/capabilities/file.ts) so the local process and the standalone
// runtime daemon share ONE implementation. Path-only ops are re-exported
// verbatim; the two ops that need the live `fileExclusions` setting (readDir,
// searchFiles) and import-entry logging are wrapped below to inject it.
// verbatim; readDir/searchFiles are wrapped below to inject Cate's fixed
// internal exclusions, and import-entry logging is handled locally.
// -----------------------------------------------------------------------------

export {
Expand All @@ -86,15 +80,15 @@ import {
searchFiles as capSearchFiles,
} from '../../runtime/capabilities/file'
export function readDir(dirPath: string): Promise<FileTreeNode[]> {
return capReadDir(dirPath, currentExclusionSet())
return capReadDir(dirPath, exclusionSet)
}

export function searchFiles(
rootPath: string,
query: string,
opts: FileSearchOptions = {},
): Promise<FileSearchResult[]> {
return capSearchFiles(rootPath, query, currentExclusionSet(), opts)
return capSearchFiles(rootPath, query, exclusionSet, opts)
}

// ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion src/main/ipc/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export function registerHandlers(): void {
repoCwd: string,
prNumber: number,
targetPath: string,
options: { symlinkPaths?: string[] } | undefined,
options: undefined,
workspaceId?: string,
) => {
const { vcs, path, runtimeId } = vcsFor(repoCwd)
Expand Down
11 changes: 3 additions & 8 deletions src/main/ipc/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type {
RuntimeStatusEvent,
SshHostEntry,
} from '../../shared/types'
import { FILE_EXCLUSIONS } from '../../shared/types'
import { broadcastToAll } from '../windowRegistry'
import { assertAbsoluteRuntimePath, formatLocator } from '../../shared/runtimeLocator'
import {
Expand Down Expand Up @@ -183,10 +184,7 @@ export async function buildTransport(runtimeId: string, spec: RemoteConnectSpec)
distro: spec.distro,
root: spec.distroPath,
id: runtimeId,
// Same launch config the local daemon gets (main/index.ts) so a WSL host
// honors the exclusion + idle-suspend settings identically. Later live
// changes are forwarded to every connected runtime by the store.
exclusions: getSetting('fileExclusions'),
exclusions: FILE_EXCLUSIONS,
idleSuspend: getSetting('autoSuspendIdleTerminals'),
})
}
Expand Down Expand Up @@ -224,10 +222,7 @@ export async function buildTransport(runtimeId: string, spec: RemoteConnectSpec)
// the user's PATH. Cate resolves the login-shell environment at startup;
// OpenSSH must receive that same authoritative environment.
env: getShellEnv(),
// Same launch config the local daemon gets (main/index.ts) so an SSH host
// honors the exclusion + idle-suspend settings identically. Later live
// changes are forwarded to every connected runtime by the store.
exclusions: getSetting('fileExclusions'),
exclusions: FILE_EXCLUSIONS,
idleSuspend: getSetting('autoSuspendIdleTerminals'),
})
}
Expand Down
4 changes: 2 additions & 2 deletions src/main/lifecycle/telemetry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BrowserWindow, ipcMain } from 'electron'
import log from '../logger'
import { setSettingsFromMain } from '../store'
import { setUIStateFromMain } from '../uiStateStore'
import { trackAppStart, checkAndReportUpdate } from '../analytics'
import { TELEMETRY_ACKNOWLEDGE_NOTICE } from '../../shared/ipc-channels'
import { TELEMETRY_NOTICE_VERSION } from '../../shared/types'
Expand All @@ -18,6 +18,6 @@ export function fireStartupTelemetry(mainWin: BrowserWindow): void {
// is bumped. Purely informational; telemetry does not depend on it.
export function registerTelemetryNoticeHandler(): void {
ipcMain.handle(TELEMETRY_ACKNOWLEDGE_NOTICE, async () => {
await setSettingsFromMain({ telemetryNoticeAcknowledgedVersion: TELEMETRY_NOTICE_VERSION })
setUIStateFromMain('telemetryNoticeAcknowledgedVersion', TELEMETRY_NOTICE_VERSION)
})
}
9 changes: 3 additions & 6 deletions src/main/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,14 +439,14 @@ export interface VcsHost {
repoCwd: string,
branch: string,
targetPath: string,
options?: { createBranch?: boolean; baseRef?: string; symlinkPaths?: string[] },
options?: { createBranch?: boolean; baseRef?: string },
access?: FileAccessContext,
): Promise<{ path: string; branch: string }>
worktreeAddFromPr(
repoCwd: string,
prNumber: number,
targetPath: string,
options?: { symlinkPaths?: string[] },
options?: undefined,
access?: FileAccessContext,
): Promise<{ path: string; branch: string }>
worktreeRemove(repoCwd: string, worktreePath: string, options?: { force?: boolean }, access?: FileAccessContext): Promise<void>
Expand Down Expand Up @@ -498,10 +498,7 @@ export interface Runtime {
* the runtime uses its own configured root scope. */
addAllowedRoot(root: string, scopeId?: string): Promise<void>
removeAllowedRoot(root: string, scopeId?: string): Promise<void>
/** Replace this runtime's readDir/search exclusion basenames live (the
* daemon's mirror of the fileExclusions setting). For the LOCAL daemon the
* main process forwards this when the setting changes, so the file tree /
* file-name search hide the new set without an app restart. */
/** Replace this runtime's internal readDir/search exclusion basenames. */
setExclusions(names: string[]): Promise<void>
/** Toggle POSIX idle-suspend of backgrounded terminals live (the daemon's
* mirror of autoSuspendIdleTerminals). Forwarded to the LOCAL daemon when the
Expand Down
21 changes: 18 additions & 3 deletions src/main/settingsFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,18 +55,18 @@ describe('settingsFile', () => {
m.loadSettingsSync()
expect(fs.existsSync(settingsPath())).toBe(true)
const onDisk = JSON.parse(fs.readFileSync(settingsPath(), 'utf-8'))
expect(onDisk.showMinimap).toBe(DEFAULT_SETTINGS.showMinimap)
expect(onDisk.zoomSpeed).toBe(DEFAULT_SETTINGS.zoomSpeed)
expect(onDisk.cliAgentReadEnabled).toBe(true)
expect(onDisk.cliAgentControlEnabled).toBe(true)
expect(m.getSetting('showMinimap')).toBe(DEFAULT_SETTINGS.showMinimap)
expect(onDisk.showMinimap).toBeUndefined()
})

it('loads an existing settings.json over defaults', async () => {
fs.writeFileSync(settingsPath(), JSON.stringify({ terminalScrollback: 9000 }))
const m = await freshModule()
m.loadSettingsSync()
expect(m.getSetting('terminalScrollback')).toBe(9000)
expect(m.getSetting('showMinimap')).toBe(DEFAULT_SETTINGS.showMinimap)
expect(m.getSetting('zoomSpeed')).toBe(DEFAULT_SETTINGS.zoomSpeed)
})

it('validates setSetting and persists on sync flush', async () => {
Expand All @@ -83,6 +83,21 @@ describe('settingsFile', () => {
expect(onDisk.warnBeforeQuit).toBe(true)
})

it('rejects invalid enum, range, and structured values', async () => {
const m = await freshModule()
m.loadSettingsSync()

expect(m.setSetting('canvasGridStyle', 'triangles' as never)).toBe(false)
expect(m.setSetting('uiScale', 4)).toBe(false)
expect(m.setSetting('agentHookInjection', null as never)).toBe(false)
expect(m.setSetting('customShortcuts', { newTerminal: { key: 't' } } as never)).toBe(false)

expect(m.getSetting('canvasGridStyle')).toBe(DEFAULT_SETTINGS.canvasGridStyle)
expect(m.getSetting('uiScale')).toBe(DEFAULT_SETTINGS.uiScale)
expect(m.getSetting('agentHookInjection')).toEqual({})
expect(m.getSetting('customShortcuts')).toEqual({})
})

it('resets a key back to its default', async () => {
const m = await freshModule()
m.loadSettingsSync()
Expand Down
Loading