* feat(command-center): add workspace file search Use the existing workspace-aware fuzzy search so file lookup follows the file explorer's ignore and hidden-directory rules. Cmd/Ctrl+P opens a removable Files scope while the normal command center also includes file matches. * fix(command-center): stabilize file search results * fix(shortcuts): preserve project picker command * fix(command-center): keep file results while searching * feat(command-center): integrate the files scope into the search row The scope was a 24px pill with 12px text sitting inside the 16px input line, so it read as a badge dropped into the query rather than the scope of the search. Make it a leading cell of the search row instead: folder icon, input-sized label, and a hairline divider before the caret. It stretches to the row's cross size and bleeds through the header padding with negative margins, so its height is driven entirely by the input and the panel's own radius clips its corner. Toggling the scope cannot resize the header, which the new spec pins. * fix(command-center): restore the files scope as a chip The previous commit turned the scope into a flush leading cell of the header. Put the chip back: rounded, bordered, folder icon and 14px label, no divider. It stretches to the row instead of carrying a height, so it is exactly as tall as the input's line box and cannot resize the header. * refactor(shortcuts): regroup and reorder the shortcut help sections Panels was a junk drawer: the command center, file search, the shortcuts sheet itself, new terminal, the sidebars, settings, focus mode and theme cycling. The two shortcuts people reach for most, Cmd+K and Cmd+P, sat inside it below three other sections. Navigation held both workspace and tab movement. Sections are now General, Projects & Workspaces, Tabs & Panes, Layout, and Agent Input. General leads with the command center and file search. Tab movement joins Tabs & Panes, new terminal joins the tabs it creates, and Layout keeps only the sidebar and focus toggles. Row order is now data rather than a side effect of binding-definition order, so the row someone opens the sheet to find sits at the top of its section. A test fails when a new shortcut has no place in that order instead of letting it sink to the bottom unnoticed. * Keep file search failures in view
212 lines
7.4 KiB
TypeScript
212 lines
7.4 KiB
TypeScript
import { expect, type Locator, type Page } from "@playwright/test";
|
|
import { daemonWsRoutePattern } from "./daemon-port";
|
|
|
|
type WebSocketMessage = string | Buffer;
|
|
|
|
interface SurfaceRect {
|
|
height: number;
|
|
width: number;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
interface LayoutObservation {
|
|
active: boolean;
|
|
layoutShift: number;
|
|
samples: Record<string, SurfaceRect[]>;
|
|
}
|
|
|
|
type LayoutOracleWindow = Window & {
|
|
__commandCenterLayoutObservation?: LayoutObservation;
|
|
};
|
|
|
|
const SURFACE_SELECTORS = {
|
|
panel: '[data-testid="command-center-panel"]',
|
|
header: '[data-testid="command-center-header"]',
|
|
input: '[data-testid="command-center-input"]',
|
|
fileSearchStatus: '[data-testid="command-center-file-search-status"]',
|
|
results: '[data-testid="command-center-results"]',
|
|
scope: '[data-testid="command-center-files-scope"]',
|
|
fileRow: '[data-testid^="command-center-file-row-"]',
|
|
fileIcon: '[data-testid="command-center-file-icon"]',
|
|
fileLine: '[data-testid="command-center-file-line"]',
|
|
} as const;
|
|
|
|
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
|
const raw = typeof message === "string" ? message : message.toString("utf8");
|
|
try {
|
|
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
|
|
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
|
? (envelope.message as Record<string, unknown>)
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function delayDirectorySuggestionResponses(page: Page, delayMs = 400): Promise<void> {
|
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
|
const server = ws.connectToServer();
|
|
ws.onMessage((message) => server.send(message));
|
|
server.onMessage((message) => {
|
|
const sessionMessage = parseSessionMessage(message);
|
|
if (sessionMessage?.type === "directory_suggestions_response") {
|
|
setTimeout(() => ws.send(message), delayMs);
|
|
return;
|
|
}
|
|
ws.send(message);
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function failDirectorySuggestionRequests(page: Page): Promise<void> {
|
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
|
const server = ws.connectToServer();
|
|
ws.onMessage((message) => {
|
|
const sessionMessage = parseSessionMessage(message);
|
|
if (sessionMessage?.type === "directory_suggestions_request") {
|
|
const requestId = sessionMessage.requestId;
|
|
if (typeof requestId === "string") {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "rpc_error",
|
|
payload: {
|
|
requestId,
|
|
requestType: "directory_suggestions_request",
|
|
error: "Test file search transport failure.",
|
|
code: "transport",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
server.send(message);
|
|
});
|
|
server.onMessage((message) => ws.send(message));
|
|
});
|
|
}
|
|
|
|
export async function startCommandCenterLayoutObservation(page: Page): Promise<void> {
|
|
await page.evaluate((selectors) => {
|
|
const oracleWindow = window as LayoutOracleWindow;
|
|
const observation: LayoutObservation = { active: true, layoutShift: 0, samples: {} };
|
|
oracleWindow.__commandCenterLayoutObservation = observation;
|
|
|
|
const observedElements = new WeakSet<Element>();
|
|
const resizeObserver = new ResizeObserver(record);
|
|
const mutationObserver = new MutationObserver(discover);
|
|
|
|
function record(): void {
|
|
if (!observation.active) return;
|
|
for (const [name, selector] of Object.entries(selectors)) {
|
|
const element = document.querySelector<HTMLElement>(selector);
|
|
if (!element) continue;
|
|
const rect = element.getBoundingClientRect();
|
|
(observation.samples[name] ??= []).push({
|
|
height: rect.height,
|
|
width: rect.width,
|
|
x: rect.x,
|
|
y: rect.y,
|
|
});
|
|
}
|
|
}
|
|
|
|
function discover(): void {
|
|
for (const selector of Object.values(selectors)) {
|
|
const element = document.querySelector(selector);
|
|
if (!element || observedElements.has(element)) continue;
|
|
observedElements.add(element);
|
|
resizeObserver.observe(element);
|
|
}
|
|
record();
|
|
}
|
|
|
|
mutationObserver.observe(document.body, { attributes: true, childList: true, subtree: true });
|
|
discover();
|
|
|
|
const sampleFrame = () => {
|
|
if (!observation.active) {
|
|
mutationObserver.disconnect();
|
|
resizeObserver.disconnect();
|
|
return;
|
|
}
|
|
record();
|
|
requestAnimationFrame(sampleFrame);
|
|
};
|
|
requestAnimationFrame(sampleFrame);
|
|
|
|
if (typeof PerformanceObserver !== "undefined") {
|
|
try {
|
|
const performanceObserver = new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) {
|
|
observation.layoutShift += (entry as PerformanceEntry & { value?: number }).value ?? 0;
|
|
}
|
|
});
|
|
performanceObserver.observe({ type: "layout-shift", buffered: false });
|
|
} catch {
|
|
// LayoutShift is diagnostic-only on engines that do not expose it.
|
|
}
|
|
}
|
|
}, SURFACE_SELECTORS);
|
|
}
|
|
|
|
export async function expectStableCommandCenterLayout(page: Page): Promise<void> {
|
|
await page.waitForTimeout(500);
|
|
const observation = await page.evaluate(() => {
|
|
const state = (window as LayoutOracleWindow).__commandCenterLayoutObservation;
|
|
if (!state) throw new Error("Command center layout observer was not installed");
|
|
state.active = false;
|
|
return state;
|
|
});
|
|
|
|
for (const surface of Object.keys(SURFACE_SELECTORS)) {
|
|
const samples = observation.samples[surface] ?? [];
|
|
expect(samples.length, `${surface}: ${JSON.stringify(observation)}`).toBeGreaterThan(0);
|
|
for (const dimension of ["height", "width", "x", "y"] as const) {
|
|
const values = samples.map((sample) => sample[dimension]);
|
|
expect(
|
|
Math.max(...values) - Math.min(...values),
|
|
`${surface}.${dimension}: ${JSON.stringify(samples)}`,
|
|
).toBeLessThanOrEqual(1);
|
|
}
|
|
}
|
|
expect(observation.layoutShift, JSON.stringify(observation)).toBeLessThanOrEqual(0.001);
|
|
}
|
|
|
|
export async function expectOneLineTruncatedFileResult(
|
|
row: Locator,
|
|
input: { filename: string; path: string },
|
|
): Promise<void> {
|
|
await expect(row).toContainText(`${input.filename} ${input.path}`);
|
|
await expect(row.getByTestId("command-center-file-icon")).toBeVisible();
|
|
const line = row.getByTestId("command-center-file-line");
|
|
const filename = row.getByTestId("command-center-file-name");
|
|
const filePath = row.getByTestId("command-center-file-path");
|
|
const geometry = await Promise.all([
|
|
row.boundingBox(),
|
|
line.boundingBox(),
|
|
filename.boundingBox(),
|
|
filePath.boundingBox(),
|
|
line.evaluate((element) => ({
|
|
clientWidth: element.clientWidth,
|
|
overflow: getComputedStyle(element).overflow,
|
|
scrollWidth: element.scrollWidth,
|
|
whiteSpace: getComputedStyle(element).whiteSpace,
|
|
})),
|
|
]);
|
|
const [rowBox, lineBox, filenameBox, pathBox, overflow] = geometry;
|
|
expect(rowBox).not.toBeNull();
|
|
expect(lineBox).not.toBeNull();
|
|
expect(filenameBox).not.toBeNull();
|
|
expect(pathBox).not.toBeNull();
|
|
expect(rowBox?.height).toBe(36);
|
|
expect(Math.abs((filenameBox?.y ?? 0) - (pathBox?.y ?? 0))).toBeLessThanOrEqual(1);
|
|
expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth);
|
|
expect(overflow.overflow).toBe("hidden");
|
|
expect(overflow.whiteSpace).toBe("nowrap");
|
|
}
|