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
7 changes: 6 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ export function App() {
const [actions, setActions] = useSearchResults<Action>(window.nvm.search, query, refreshNonce)
const [iconUrls, setIconUrls] = useState<Record<string, string | null>>({})
const [runningAppPaths, setRunningAppPaths] = useState<Record<string, boolean>>({})
const [runningAppPathsRefreshNonce, setRunningAppPathsRefreshNonce] = useState(0)
const [selectedValue, setSelectedValue] = useState('')
const selectedValueRef = useRef('')
const [optionsFor, setOptionsFor] = useState<Action | null>(null)
Expand Down Expand Up @@ -665,6 +666,10 @@ export function App() {
}
}, [actions])

useEffect(() => window.nvm.onRunningAppPathsChanged(() => {
setRunningAppPathsRefreshNonce((nonce) => nonce + 1)
}), [])

useEffect(() => {
const appPaths = Array.from(new Set(actions.map(appPathForRunningStatus).filter(Boolean) as string[]))
if (!appPaths.length) return
Expand All @@ -685,7 +690,7 @@ export function App() {
return changed ? next : current
})
}).catch(() => {})
}, [actions])
}, [actions, runningAppPathsRefreshNonce])

const selectedAction = useMemo(
() => actions.find((action) => action.id === selectedValue),
Expand Down
57 changes: 57 additions & 0 deletions src/docs/solutions/running-app-status-background-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Running app status blocked palette responsiveness

## Problem or symptoms

The palette felt laggy or unusable while opening, searching, or rendering app results. Performance logs showed slow decorative running-app status checks:

- `apps.running.snapshot` around 800–1460ms
- `apps.running.get` / `ipc.apps:running-paths` waiting on that snapshot
- palette `showPalette.later` callbacks delayed by roughly 1.2–3.5s

## Context

Root app results render a running/open indicator via `runningAppClassName` in `src/App.tsx`. That status is visual decoration; it must never gate user-visible search, input, command execution, or palette reveal.

Relevant paths:

- `src/App.tsx`
- `src/electron/main.ts`
- `src/electron/os.ts`
- `src/electron/preload.ts`
- `src/preload-api.ts`

## What did not work

Only making the macOS running-app detector faster was a symptom patch. Replacing a slow native call with a faster native call still left a decorative UI affordance on the renderer-to-main request path, so future regressions could again make palette interactions wait on OS process inspection.

## Root cause

`apps:running-paths` awaited `runningAppPathSnapshot()`. When the cached snapshot was missing or stale, the IPC handler synchronously waited for native running-app detection before responding to the renderer.

On macOS, the detector used AppleScript through System Events, which could take around a second. Multiple renderer requests then piled up behind the same snapshot work, making the palette feel blocked even though the running indicator was not required for primary results.

## Fix

Treat running-app status as stale-while-revalidate decoration:

- `apps:running-paths` returns immediately from the last cached snapshot, or an empty snapshot if none exists yet.
- A stale or missing snapshot schedules background refresh and does not block the IPC response.
- The host dedupes in-flight refreshes.
- When the refreshed snapshot changes, main sends `apps:running-paths-changed` to the renderer.
- The renderer listens for that event and re-queries only to update row decoration.
- macOS detection uses bounded `ps` process inspection instead of AppleScript, so the background refresh is cheaper too.

## Verification

Run:

```sh
mise exec -- pnpm typecheck
mise exec -- pnpm build
```

Both passed after the change.

## Notes for future searches

Keywords: running app indicator, open app marker, `apps:running-paths`, `apps.running.snapshot`, `apps.running.get`, `ipc.apps:running-paths`, stale-while-revalidate, background refresh, decorative status, AppleScript, System Events, `ps -axo comm=`, palette lag, user actions should be instant.
31 changes: 24 additions & 7 deletions src/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -699,26 +699,42 @@ function normalizedRunningPath(value) {
return process.platform === 'darwin' || process.platform === 'win32' ? text.toLowerCase() : text
}

async function runningAppPathSnapshot() {
const now = Date.now()
if (runningAppsSnapshot && now - runningAppsSnapshot.updatedAt < RUNNING_APPS_SNAPSHOT_TTL_MS) return runningAppsSnapshot.paths
function runningSnapshotIsFresh() {
return Boolean(runningAppsSnapshot && Date.now() - runningAppsSnapshot.updatedAt < RUNNING_APPS_SNAPSHOT_TTL_MS)
}

function sameRunningPathSet(a: Set<string> | undefined, b: Set<string>) {
if (!a || a.size !== b.size) return false
for (const item of a) if (!b.has(item)) return false
return true
}

function refreshRunningAppPathSnapshot(reason: string) {
if (runningAppsRefresh) return runningAppsRefresh
runningAppsRefresh = measureDebugPerformance('apps.running.snapshot', { indexedCount: appIndex.length, alwaysLog: true }, async () => {
const previousPaths = runningAppsSnapshot?.paths
runningAppsRefresh = measureDebugPerformance('apps.running.snapshot', { indexedCount: appIndex.length, reason, alwaysLog: true }, async () => {
const paths = await detectRunningAppPaths(appIndex)
runningAppsSnapshot = { updatedAt: Date.now(), paths }
markDebugPerformance('apps.running.snapshot.result', { count: paths.size })
markDebugPerformance('apps.running.snapshot.result', { count: paths.size, reason })
if (!sameRunningPathSet(previousPaths, paths)) paletteWindow.win?.webContents.send('apps:running-paths-changed')
return paths
}).finally(() => {
runningAppsRefresh = null
})
return runningAppsRefresh
}

function scheduleRunningAppPathSnapshotRefresh(reason: string) {
if (runningSnapshotIsFresh() || runningAppsRefresh) return
void refreshRunningAppPathSnapshot(reason).catch((error) => logWarn('apps.running.snapshot.failed', error, { source: 'host', scope: 'apps' }))
}

async function runningAppPathsForRenderer(appPaths) {
return measureDebugPerformance('apps.running.get', { requestedCount: Array.isArray(appPaths) ? appPaths.length : 0, alwaysLog: true }, async () => {
return measureDebugPerformance('apps.running.get', { requestedCount: Array.isArray(appPaths) ? appPaths.length : 0, cached: Boolean(runningAppsSnapshot), alwaysLog: true }, async () => {
const requestedPaths = Array.from(new Set((Array.isArray(appPaths) ? appPaths : []).map((item) => String(item || '').trim()).filter(Boolean))).slice(0, 30)
if (!requestedPaths.length) return []
const runningPaths = await runningAppPathSnapshot()
scheduleRunningAppPathSnapshotRefresh('renderer-request')
const runningPaths = runningAppsSnapshot?.paths || new Set<string>()
return requestedPaths.filter((appPath) => runningPaths.has(normalizedRunningPath(appPath)))
})
}
Expand Down Expand Up @@ -4791,6 +4807,7 @@ async function indexApplications() {
runningAppsSnapshot = null
markDebugPerformance('apps.index.result', { scannedCount: apps.length, indexedCount: appIndex.length })
paletteWindow.win?.webContents.send('apps:indexed', appIndex.length)
scheduleRunningAppPathSnapshotRefresh('apps-indexed')
} catch (error) {
logError('applications.index.failed', error, { source: 'host', scope: 'apps' })
}
Expand Down
42 changes: 25 additions & 17 deletions src/electron/os.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,22 +214,30 @@ function normalizedRunningPath(value: unknown) {
return process.platform === 'darwin' || process.platform === 'win32' ? text.toLowerCase() : text
}

async function runningMacAppPaths() {
const script = `set outputLines to {}
tell application "System Events"
repeat with appProcess in (application processes whose background only is false)
set appPath to ""
try
set appPath to POSIX path of (file of appProcess as alias)
end try
if appPath is not "" then set end of outputLines to appPath
end repeat
end tell
set AppleScript's text item delimiters to linefeed
return outputLines as text`
const result = await runAppleScript(script, 5_000)
if (result.exitCode !== 0) return new Set<string>()
return new Set(result.stdout.split(/\r?\n/).map((item) => normalizedRunningPath(item)).filter(Boolean))
async function macProcessExecutablePaths() {
return new Promise<string[]>((resolve) => {
execFile('ps', ['-axo', 'comm='], { timeout: 1_000, maxBuffer: 1024 * 1024 }, (error, stdout) => {
if (error) return resolve([])
resolve(stdout.split(/\r?\n/).map((item) => item.trim()).filter(Boolean))
})
})
}

function macExecutableBelongsToApp(executablePath: string, appPath: string) {
const normalizedExecutable = normalizedRunningPath(executablePath)
const normalizedAppPath = normalizedRunningPath(appPath).replace(/\/+$/, '')
return normalizedExecutable.startsWith(`${normalizedAppPath}/contents/`)
}

async function runningMacAppPaths(apps: RunningAppCandidate[] = []) {
const candidates = Array.from(new Set(apps.map((item) => item.path || item.id).filter((item): item is string => Boolean(item?.endsWith('.app')))))
if (!candidates.length) return new Set<string>()
const executables = await macProcessExecutablePaths()
const running = new Set<string>()
for (const appPath of candidates) {
if (executables.some((executablePath) => macExecutableBelongsToApp(executablePath, appPath))) running.add(normalizedRunningPath(appPath))
}
return running
}

function shellWords(command: string) {
Expand Down Expand Up @@ -274,7 +282,7 @@ async function runningLinuxAppPaths(apps: RunningAppCandidate[] = []) {
}

export async function runningAppPaths(apps: RunningAppCandidate[] = []) {
return osFunction({ darwin: runningMacAppPaths, linux: () => runningLinuxAppPaths(apps) }, async () => new Set<string>())()
return osFunction({ darwin: () => runningMacAppPaths(apps), linux: () => runningLinuxAppPaths(apps) }, async () => new Set<string>())()
}

export function watchApps(onChange: () => void) {
Expand Down
5 changes: 5 additions & 0 deletions src/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ const api: NevermindApi = {
ipcRenderer.on('apps:indexed', listener)
return () => ipcRenderer.removeListener('apps:indexed', listener)
},
onRunningAppPathsChanged: (callback) => {
const listener = () => callback()
ipcRenderer.on('apps:running-paths-changed', listener)
return () => ipcRenderer.removeListener('apps:running-paths-changed', listener)
},
onClipboardChanged: (callback) => {
const listener = () => callback()
ipcRenderer.on('clipboard:changed', listener)
Expand Down
1 change: 1 addition & 0 deletions src/preload-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export type NevermindApi = {
onShortcutShown: (callback: () => void) => () => void
onHidden: (callback: () => void) => () => void
onAppsIndexed: (callback: (count: number) => void) => () => void
onRunningAppPathsChanged: (callback: () => void) => () => void
onClipboardChanged: (callback: () => void) => () => void
onRootItemsChanged: (callback: () => void) => () => void
onOpenActionView: (callback: (payload?: OpenActionViewPayload) => void) => () => void
Expand Down
Loading