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
9 changes: 9 additions & 0 deletions apps/desktop/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,15 @@ declare global {
>;
saveArtifactAs(artifactId: string): Promise<ArtifactSaveResult>;
};
workspace: {
searchFiles(
query: string,
limit?: number,
): Promise<
| { ok: true; files: Array<{ relativePath: string }> }
| { ok: false; reason: 'no_project' | 'search_failed' }
>;
};
visualSmoke: {
getState(): Promise<VisualSmokeState | null>;
capture(input: { scenario: string; variant: string }): Promise<
Expand Down
81 changes: 81 additions & 0 deletions apps/desktop/src/main/__tests__/composer-mention-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Contract for the composer `@`/`/` mention popups
* (feat/composer-mentions, notes/composer-mentions-spec-2026-07-14.md).
*
* Pins the fragile ordering + SSR-safety guarantees:
* 1. The mention-popup keyboard branch runs BEFORE the Esc/drag branch and
* BEFORE the send fall-through, so Enter selects a mention (never sends).
* 2. The mention Escape branch closes ONLY the popup — it must not call
* setDragActive or props.onStop (Esc-with-popup keeps the drag highlight
* and never stops the stream).
* 3. Composer rendered with minimal props (no mention props) stays green and
* renders no listbox — the mention feature is fully inert without wiring.
*/

import { strict as assert } from 'node:assert';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { describe, it } from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { Composer } from '@maka/ui';

const COMPOSER_TSX = join(process.cwd(), '../../packages/ui/src/composer.tsx');

function keydownBody(source: string): string {
return source.match(/function onTextareaKeyDown\(event: KeyboardEvent<HTMLTextAreaElement>\) \{[\s\S]*?\n \}/)?.[0] ?? '';
}

describe('composer mention popup contract', () => {
it('runs the mention keyboard branch before the Esc/drag and send branches', async () => {
const source = await readFile(COMPOSER_TSX, 'utf8');
const keydown = keydownBody(source);
assert.notEqual(keydown, '', 'onTextareaKeyDown body must be found');

const mentionAt = keydown.indexOf('if (mentionPopupOpen) {');
const escDragAt = keydown.indexOf("event.key === 'Escape' && dragActive");
const streamingAt = keydown.indexOf("event.key === 'Escape' && props.streaming");
const sendAt = keydown.indexOf("if (event.key !== 'Enter') return;");

assert.ok(mentionAt >= 0, 'mention popup branch must exist in onTextareaKeyDown');
assert.ok(escDragAt >= 0 && streamingAt >= 0 && sendAt >= 0, 'anchor branches must exist');
assert.ok(mentionAt < escDragAt, 'mention branch must precede the Esc/drag branch');
assert.ok(mentionAt < streamingAt, 'mention branch must precede the streaming Esc branch');
assert.ok(mentionAt < sendAt, 'mention branch must precede the Enter→send fall-through');
});

it('Enter/Tab select a mention with preventDefault (so Enter cannot send)', async () => {
const source = await readFile(COMPOSER_TSX, 'utf8');
const keydown = keydownBody(source);
const mentionBlock = keydown.slice(keydown.indexOf('if (mentionPopupOpen) {'));
assert.match(
mentionBlock,
/if \(event\.key === 'Enter' \|\| event\.key === 'Tab'\) \{[\s\S]*?event\.preventDefault\(\);[\s\S]*?selectMention\(mentionActiveIndex\);/,
'Enter/Tab with items must preventDefault and select the active mention',
);
});

it('the mention Escape branch closes only the popup (no drag clear, no stop)', async () => {
const source = await readFile(COMPOSER_TSX, 'utf8');
const keydown = keydownBody(source);
const mentionStart = keydown.indexOf('if (mentionPopupOpen) {');
const escDragAt = keydown.indexOf("event.key === 'Escape' && dragActive");
// The slice of the mention branch that precedes the drag branch.
const mentionBlock = keydown.slice(mentionStart, escDragAt);
assert.match(
mentionBlock,
/if \(event\.key === 'Escape'\) \{[\s\S]*?event\.preventDefault\(\);[\s\S]*?closeMention\(\);[\s\S]*?return;/,
'popup-open Escape must preventDefault + closeMention + return',
);
assert.doesNotMatch(mentionBlock, /setDragActive/, 'mention branch must not touch drag state');
assert.doesNotMatch(mentionBlock, /onStop/, 'mention branch must not stop the stream');
});

it('renders inert (no listbox) when mention props are absent (SSR minimal props)', () => {
const markup = renderToStaticMarkup(
createElement(Composer, { onSend: () => {}, onStop: () => {} }),
);
assert.doesNotMatch(markup, /role="listbox"/, 'no popup without mention props');
assert.doesNotMatch(markup, /maka-composer-mention-popup/, 'popup markup must be absent');
});
});
144 changes: 144 additions & 0 deletions apps/desktop/src/main/__tests__/workspace-file-search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { strict as assert } from 'node:assert';
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import { describe, it } from 'node:test';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { ExecFileException } from 'node:child_process';
import { searchWorkspaceFiles } from '../workspace-file-search.js';

type ExecFileCallback = (
file: string,
args: readonly string[],
options: { cwd: string; timeout: number; windowsHide: boolean; maxBuffer: number },
cb: (error: ExecFileException | null, stdout: string, stderr: string) => void,
) => void;

/** Fake `git ls-files` returning a fixed newline-joined path list. */
function fakeGit(stdout: string, error: ExecFileException | null = null): ExecFileCallback {
return (_file, args, _options, cb) => {
assert.deepEqual(args, ['ls-files', '--cached', '--others', '--exclude-standard']);
cb(error, stdout, '');
};
}

async function withGitRepo(run: (root: string) => Promise<void>): Promise<void> {
const root = await mkdtemp(join(tmpdir(), 'maka-wfs-git-'));
await mkdir(join(root, '.git'), { recursive: true });
await writeFile(join(root, '.git', 'HEAD'), 'ref: refs/heads/main\n', 'utf8');
try {
await run(root);
} finally {
await rm(root, { recursive: true, force: true });
}
}

async function withPlainDir(run: (root: string) => Promise<void>): Promise<void> {
const root = await mkdtemp(join(tmpdir(), 'maka-wfs-plain-'));
try {
await run(root);
} finally {
await rm(root, { recursive: true, force: true });
}
}

describe('searchWorkspaceFiles', () => {
it('lists git-tracked/untracked files honoring the ls-files output', async () => {
await withGitRepo(async (root) => {
const execFileImpl = fakeGit('src/app.tsx\nsrc/main.tsx\nREADME.md\n');
const result = await searchWorkspaceFiles(root, { query: '', execFileImpl });
assert.equal(result.ok, true);
assert.ok(result.ok && result.files.some((f) => f.relativePath === 'src/app.tsx'));
assert.ok(result.ok && result.files.some((f) => f.relativePath === 'README.md'));
});
});

it('filters with AND-of-substring tokens, case-insensitively', async () => {
await withGitRepo(async (root) => {
const execFileImpl = fakeGit('src/app.tsx\nsrc/main.tsx\ndocs/app.md\n');
const result = await searchWorkspaceFiles(root, { query: 'SRC app', execFileImpl });
assert.ok(result.ok);
const paths = result.ok ? result.files.map((f) => f.relativePath) : [];
assert.deepEqual(paths, ['src/app.tsx']);
});
});

it('ranks shorter paths first, then lexicographically', async () => {
await withGitRepo(async (root) => {
const execFileImpl = fakeGit('a/b/c/app.tsx\napp.tsx\nlib/app.tsx\n');
const result = await searchWorkspaceFiles(root, { query: 'app', execFileImpl });
assert.ok(result.ok);
const paths = result.ok ? result.files.map((f) => f.relativePath) : [];
assert.deepEqual(paths, ['app.tsx', 'lib/app.tsx', 'a/b/c/app.tsx']);
});
});

it('caps the result count at the requested limit', async () => {
await withGitRepo(async (root) => {
const many = Array.from({ length: 200 }, (_v, i) => `file-${i}.ts`).join('\n');
const execFileImpl = fakeGit(many);
const result = await searchWorkspaceFiles(root, { query: '', limit: 5, execFileImpl });
assert.ok(result.ok);
assert.equal(result.ok ? result.files.length : -1, 5);
});
});

it('falls back to a readdir walk when the tree is not a git repo', async () => {
await withPlainDir(async (root) => {
await mkdir(join(root, 'src'), { recursive: true });
await writeFile(join(root, 'src', 'index.ts'), '', 'utf8');
await writeFile(join(root, 'top.md'), '', 'utf8');
// Should be skipped by the walk.
await mkdir(join(root, 'node_modules', 'pkg'), { recursive: true });
await writeFile(join(root, 'node_modules', 'pkg', 'ignored.js'), '', 'utf8');

const result = await searchWorkspaceFiles(root, { query: '' });
assert.ok(result.ok);
const paths = result.ok ? result.files.map((f) => f.relativePath) : [];
assert.ok(paths.includes('src/index.ts'));
assert.ok(paths.includes('top.md'));
assert.ok(!paths.some((p) => p.includes('node_modules')), 'node_modules must be skipped');
});
});

it('never returns paths outside the root and does not follow symlinked dirs', async () => {
await withPlainDir(async (root) => {
const outside = await mkdtemp(join(tmpdir(), 'maka-wfs-outside-'));
try {
await writeFile(join(outside, 'secret.txt'), '', 'utf8');
await writeFile(join(root, 'inside.txt'), '', 'utf8');
try {
await symlink(outside, join(root, 'link'), 'dir');
} catch {
// Some sandboxes disallow symlinks — the containment assertion below
// still holds for the real files.
}
const result = await searchWorkspaceFiles(root, { query: '' });
assert.ok(result.ok);
const paths = result.ok ? result.files.map((f) => f.relativePath) : [];
assert.ok(paths.includes('inside.txt'));
assert.ok(!paths.some((p) => p.includes('secret') || p.startsWith('..')), 'must not escape root via symlink');
} finally {
await rm(outside, { recursive: true, force: true });
}
});
});

it('falls back to the walk when git ls-files errors', async () => {
await withGitRepo(async (root) => {
await writeFile(join(root, 'walked.ts'), '', 'utf8');
const failing: ExecFileCallback = (_f, _a, _o, cb) => {
const err = new Error('boom') as ExecFileException;
cb(err, '', 'fatal');
};
const result = await searchWorkspaceFiles(root, { query: 'walked', execFileImpl: failing });
assert.ok(result.ok);
assert.ok(result.ok && result.files.some((f) => f.relativePath === 'walked.ts'));
});
});

it('returns no_project when the root is empty', async () => {
const result = await searchWorkspaceFiles('', { query: 'x' });
assert.equal(result.ok, false);
assert.equal(!result.ok && result.reason, 'no_project');
});
});
13 changes: 13 additions & 0 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ import { probeOfficeCli } from './officecli-probe.js';
import { resolveOpenPath, type OpenPathResult } from './open-path-guard.js';
import { resolveProjectGitInfo, resolveProjectRoot } from '@maka/runtime';
import { listLocalBranches, checkoutBranch } from './git-branch.js';
import { searchWorkspaceFiles } from './workspace-file-search.js';
import { createDailyReviewArchiveStore } from './daily-review-archive-store.js';
import { botTestErrorMessage, buildSettingsUpdateResult, maskAppSettings, preserveSensitivePlaceholders, toSettingsTestResult } from './settings-ipc-helpers.js';
import {
Expand Down Expand Up @@ -1259,6 +1260,18 @@ function registerIpc(): void {
return checkoutBranch(projectPath, branch);
},
);
// Composer `@` mention popup: list workspace files under the same project
// root that app:info reports. Git repos honor .gitignore + untracked via
// `git ls-files`; other trees fall back to a bounded walk. See
// workspace-file-search.ts.
ipcMain.handle(
'workspace:searchFiles',
async (_event, input: unknown) => {
const request = (input ?? {}) as { query?: unknown; limit?: unknown };
const projectPath = await currentProjectRoot();
return searchWorkspaceFiles(projectPath, { query: request.query, limit: request.limit });
},
);
registerMemoryIpc({ localMemory });
registerConfigIpc({ connectionStore, settingsStore, credentialStore, workspaceRoot });
registerNotificationsIpc({ settingsStore, mainWindowController, e2e: isE2e });
Expand Down
127 changes: 127 additions & 0 deletions apps/desktop/src/main/workspace-file-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { execFile, type ExecFileException } from 'node:child_process';
import { readdir } from 'node:fs/promises';
import { join, relative, sep } from 'node:path';
import { resolveProjectGitInfo } from '@maka/runtime';

/**
* workspace-file-search.ts — local-only workspace file listing for the composer
* `@` mention popup. Mirrors git-branch.ts: we shell out to `git ls-files` when
* the project root is a git repo (so .gitignore + untracked files are honored
* exactly as the user expects) and fall back to a bounded recursive readdir
* walk otherwise. `execFileImpl` is injectable so unit tests can fake git.
*
* Returned `relativePath`s are always POSIX-style (forward slashes) and always
* inside the project root — the git path list is repo-relative by construction,
* and the walk never follows symlinked directories or escapes the root.
*/

export type WorkspaceFileSearchResult =
| { ok: true; files: Array<{ relativePath: string }> }
| { ok: false; reason: 'no_project' | 'search_failed' };

const LS_TIMEOUT_MS = 3_000;
const DEFAULT_LIMIT = 50;
/** Cap the fallback walk so a huge non-git tree can't stall the popup. */
const MAX_WALK_ENTRIES = 5_000;
const SKIP_DIRS = new Set(['.git', 'node_modules']);

type ExecFileCallback = (
file: string,
args: readonly string[],
options: { cwd: string; timeout: number; windowsHide: boolean; maxBuffer: number },
cb: (error: ExecFileException | null, stdout: string, stderr: string) => void,
) => void;

/** AND-of-substring token match, case-insensitive — the same rule the composer
* uses client-side (kept local so the main process doesn't import @maka/ui). */
function matchesAllTokens(tokens: readonly string[], text: string): boolean {
const haystack = text.toLowerCase();
return tokens.every((token) => haystack.includes(token));
}

function toPosix(path: string): string {
return sep === '/' ? path : path.split(sep).join('/');
}

function runGitLsFiles(
cwd: string,
execFileImpl: ExecFileCallback,
): Promise<{ ok: true; files: string[] } | { ok: false }> {
return new Promise((resolve) => {
execFileImpl(
'git',
['ls-files', '--cached', '--others', '--exclude-standard'],
{ cwd, timeout: LS_TIMEOUT_MS, windowsHide: true, maxBuffer: 32 * 1024 * 1024 },
(error, stdout) => {
if (error) {
resolve({ ok: false });
return;
}
const files = stdout.split('\n').map((line) => line.trim()).filter(Boolean);
resolve({ ok: true, files });
},
);
});
}

/** Bounded recursive walk. Skips node_modules/.git, never recurses into
* symlinked directories (dirent.isDirectory() is false for a symlink), and
* stops once MAX_WALK_ENTRIES files are collected. */
async function walkFiles(root: string): Promise<string[]> {
const out: string[] = [];
const stack: string[] = [root];
while (stack.length > 0 && out.length < MAX_WALK_ENTRIES) {
const dir = stack.pop()!;
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
continue; // unreadable dir — skip rather than fail the whole walk
}
for (const entry of entries) {
if (out.length >= MAX_WALK_ENTRIES) break;
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
stack.push(join(dir, entry.name));
} else if (entry.isFile()) {
out.push(toPosix(relative(root, join(dir, entry.name))));
}
// Symlinks (isDirectory()/isFile() both false) are intentionally ignored.
}
}
return out;
}

export async function searchWorkspaceFiles(
projectRoot: string,
input: { query?: unknown; limit?: unknown; execFileImpl?: ExecFileCallback } = {},
): Promise<WorkspaceFileSearchResult> {
if (typeof projectRoot !== 'string' || !projectRoot) {
return { ok: false, reason: 'no_project' };
}
const query = typeof input.query === 'string' ? input.query : '';
const limit = typeof input.limit === 'number' && input.limit > 0 ? Math.floor(input.limit) : DEFAULT_LIMIT;
const execFileImpl = input.execFileImpl ?? (execFile as unknown as ExecFileCallback);

try {
let candidates: string[];
const info = await resolveProjectGitInfo(projectRoot);
if (info.isGitRepo) {
const listed = await runGitLsFiles(projectRoot, execFileImpl);
candidates = listed.ok ? listed.files : await walkFiles(projectRoot);
} else {
candidates = await walkFiles(projectRoot);
}

const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
const filtered = tokens.length === 0
? candidates
: candidates.filter((path) => matchesAllTokens(tokens, path));
// Rank shorter paths first (usually the closest / most relevant match),
// then lexicographically for a stable order.
filtered.sort((a, b) => a.length - b.length || a.localeCompare(b));
return { ok: true, files: filtered.slice(0, limit).map((relativePath) => ({ relativePath })) };
} catch {
return { ok: false, reason: 'search_failed' };
}
}
Loading
Loading