From cdd5a9906b9bb4d0aa4e5ee2ceadee35b63b98b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:55:29 +0000 Subject: [PATCH 1/2] Initial plan From 185fb4a99889e1765219ddd215faad18c98027ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 01:05:26 +0000 Subject: [PATCH 2/2] Add suggestion preview Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- frontend/app/suggestion/suggestion.tsx | 7 +- frontend/app/view/preview/preview.tsx | 40 +--- .../app/view/preview/previewsuggestions.ts | 46 ++++ frontend/preview/mock/mocksuggestions.ts | 201 ++++++++++++++++++ frontend/preview/mock/mockwaveenv.ts | 3 + .../previews/suggestion.preview.test.ts | 50 +++++ .../preview/previews/suggestion.preview.tsx | 70 ++++++ 7 files changed, 380 insertions(+), 37 deletions(-) create mode 100644 frontend/app/view/preview/previewsuggestions.ts create mode 100644 frontend/preview/mock/mocksuggestions.ts create mode 100644 frontend/preview/previews/suggestion.preview.test.ts create mode 100644 frontend/preview/previews/suggestion.preview.tsx diff --git a/frontend/app/suggestion/suggestion.tsx b/frontend/app/suggestion/suggestion.tsx index 9fdf74b241..5b3e842a9d 100644 --- a/frontend/app/suggestion/suggestion.tsx +++ b/frontend/app/suggestion/suggestion.tsx @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { atoms } from "@/app/store/global"; @@ -33,12 +33,15 @@ function SuggestionControl({ onTab, fetchSuggestions, className, + placeholderText, children, }: SuggestionControlProps) { if (!isOpen || !anchorRef.current || !fetchSuggestions) return null; return ( - + ); } diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx index 33188ae5b5..a3bdd52d7b 100644 --- a/frontend/app/view/preview/preview.tsx +++ b/frontend/app/view/preview/preview.tsx @@ -2,11 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { CenteredDiv } from "@/app/element/quickelems"; -import { TabRpcClient } from "@/app/store/wshrpcutil"; import { BlockHeaderSuggestionControl } from "@/app/suggestion/suggestion"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import { globalStore } from "@/store/global"; -import { isBlank, makeConnRoute } from "@/util/util"; +import { isBlank } from "@/util/util"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { memo, useEffect } from "react"; import { CSVView } from "./csvview"; @@ -15,6 +14,7 @@ import { CodeEditPreview } from "./preview-edit"; import { ErrorOverlay } from "./preview-error-overlay"; import { MarkdownPreview } from "./preview-markdown"; import type { PreviewModel } from "./preview-model"; +import { fetchPreviewFileSuggestions } from "./previewsuggestions"; import { StreamingPreview } from "./preview-streaming"; import type { PreviewEnv } from "./previewenv"; @@ -64,38 +64,6 @@ const SpecializedView = memo(({ parentRef, model }: SpecializedViewProps) => { return ; }); -const fetchSuggestions = async ( - env: PreviewEnv, - model: PreviewModel, - query: string, - reqContext: SuggestionRequestContext -): Promise => { - const conn = await globalStore.get(model.connection); - let route = makeConnRoute(conn); - if (isBlank(conn)) { - route = null; - } - if (reqContext?.dispose) { - env.rpc.DisposeSuggestionsCommand(TabRpcClient, reqContext.widgetid, { noresponse: true, route: route }); - return null; - } - const fileInfo = await globalStore.get(model.statFile); - if (fileInfo == null) { - return null; - } - const sdata = { - suggestiontype: "file", - "file:cwd": fileInfo.path, - query: query, - widgetid: reqContext.widgetid, - reqnum: reqContext.reqnum, - "file:connection": conn, - }; - return await env.rpc.FetchSuggestionsCommand(TabRpcClient, sdata, { - route: route, - }); -}; - function PreviewView({ blockRef, contentRef, @@ -143,7 +111,9 @@ function PreviewView({ } }; const fetchSuggestionsFn = async (query, ctx) => { - return await fetchSuggestions(env, model, query, ctx); + const conn = await globalStore.get(model.connection); + const cwd = globalStore.get(model.statFile)?.path; + return await fetchPreviewFileSuggestions(env, query, ctx, { cwd, connection: conn }); }; return ( diff --git a/frontend/app/view/preview/previewsuggestions.ts b/frontend/app/view/preview/previewsuggestions.ts new file mode 100644 index 0000000000..090c41721b --- /dev/null +++ b/frontend/app/view/preview/previewsuggestions.ts @@ -0,0 +1,46 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; +import { isBlank, makeConnRoute } from "@/util/util"; + +export type PreviewSuggestionsEnv = WaveEnvSubset<{ + rpc: { + FetchSuggestionsCommand: WaveEnv["rpc"]["FetchSuggestionsCommand"]; + DisposeSuggestionsCommand: WaveEnv["rpc"]["DisposeSuggestionsCommand"]; + }; +}>; + +type FetchPreviewFileSuggestionsOpts = { + cwd?: string; + connection?: string; +}; + +export async function fetchPreviewFileSuggestions( + env: PreviewSuggestionsEnv, + query: string, + reqContext: SuggestionRequestContext, + opts?: FetchPreviewFileSuggestionsOpts +): Promise { + let route = makeConnRoute(opts?.connection); + if (isBlank(opts?.connection)) { + route = null; + } + if (reqContext?.dispose) { + env.rpc.DisposeSuggestionsCommand(TabRpcClient, reqContext.widgetid, { noresponse: true, route }); + return null; + } + return await env.rpc.FetchSuggestionsCommand( + TabRpcClient, + { + suggestiontype: "file", + "file:cwd": opts?.cwd ?? "~", + query, + widgetid: reqContext.widgetid, + reqnum: reqContext.reqnum, + "file:connection": opts?.connection, + }, + { route } + ); +} diff --git a/frontend/preview/mock/mocksuggestions.ts b/frontend/preview/mock/mocksuggestions.ts new file mode 100644 index 0000000000..434af82cf7 --- /dev/null +++ b/frontend/preview/mock/mocksuggestions.ts @@ -0,0 +1,201 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { DefaultMockFilesystem } from "./mockfilesystem"; + +const MaxMockSuggestions = 50; + +type ResolvedMockFileQuery = { + baseDir: string; + queryPrefix: string; + searchTerm: string; +}; + +function ensureTrailingSlash(path: string): string { + if (path === "" || path.endsWith("/")) { + return path; + } + return path + "/"; +} + +function trimTrailingSlash(path: string): string { + if (path === "/") { + return path; + } + return path.replace(/\/+$/, ""); +} + +function getDirName(path: string): string { + const trimmedPath = trimTrailingSlash(path); + if (trimmedPath === "/") { + return "/"; + } + const idx = trimmedPath.lastIndexOf("/"); + if (idx <= 0) { + return "/"; + } + return trimmedPath.slice(0, idx); +} + +function getBaseName(path: string): string { + const trimmedPath = trimTrailingSlash(path); + if (trimmedPath === "/") { + return "/"; + } + const idx = trimmedPath.lastIndexOf("/"); + return idx < 0 ? trimmedPath : trimmedPath.slice(idx + 1); +} + +function expandMockHome(path: string): string { + if (path === "~") { + return DefaultMockFilesystem.homePath; + } + if (path.startsWith("~/")) { + return DefaultMockFilesystem.homePath + path.slice(1); + } + return path; +} + +function normalizeMockPath(path: string, basePath = DefaultMockFilesystem.homePath): string { + if (path == null || path === "") { + return basePath; + } + path = expandMockHome(path); + if (!path.startsWith("/")) { + path = `${basePath}/${path}`; + } + const resolvedParts: string[] = []; + for (const part of path.split("/")) { + if (part === "" || part === ".") { + continue; + } + if (part === "..") { + resolvedParts.pop(); + continue; + } + resolvedParts.push(part); + } + return "/" + resolvedParts.join("/"); +} + +function resolveMockFileQuery(cwd: string, query: string): ResolvedMockFileQuery { + const resolvedCwd = normalizeMockPath(cwd || "~", "/"); + if (query == null || query === "") { + return { baseDir: resolvedCwd, queryPrefix: "", searchTerm: "" }; + } + if (query === "~" || query === "~/") { + return { baseDir: DefaultMockFilesystem.homePath, queryPrefix: "~/", searchTerm: "" }; + } + const expandedQuery = expandMockHome(query); + if (expandedQuery.startsWith("/")) { + if (query.endsWith("/")) { + return { + baseDir: normalizeMockPath(expandedQuery, "/"), + queryPrefix: query, + searchTerm: "", + }; + } + if (expandedQuery === "/") { + return { baseDir: "/", queryPrefix: "/", searchTerm: "" }; + } + return { + baseDir: getDirName(expandedQuery), + queryPrefix: ensureTrailingSlash(getDirName(query)), + searchTerm: getBaseName(expandedQuery), + }; + } + if (query.endsWith("/")) { + return { + baseDir: normalizeMockPath(query, resolvedCwd), + queryPrefix: query, + searchTerm: "", + }; + } + const slashIdx = query.lastIndexOf("/"); + if (slashIdx !== -1) { + const dirPart = query.slice(0, slashIdx); + return { + baseDir: normalizeMockPath(dirPart, resolvedCwd), + queryPrefix: ensureTrailingSlash(dirPart), + searchTerm: query.slice(slashIdx + 1), + }; + } + return { baseDir: resolvedCwd, queryPrefix: "", searchTerm: query }; +} + +function findMatchPositions(value: string, searchTerm: string): number[] { + const lowerValue = value.toLowerCase(); + const lowerSearchTerm = searchTerm.toLowerCase(); + const positions: number[] = []; + let searchIdx = 0; + for (let idx = 0; idx < lowerValue.length; idx++) { + if (lowerValue[idx] !== lowerSearchTerm[searchIdx]) { + continue; + } + positions.push(idx); + searchIdx++; + if (searchIdx >= lowerSearchTerm.length) { + return positions; + } + } + return null; +} + +function scoreSuggestion(value: string, positions: number[], fallbackIndex: number): number { + if (positions.length === 0) { + return MaxMockSuggestions - fallbackIndex; + } + let score = 1000 - value.length; + if (positions[0] === 0) { + score += 500; + } + for (let idx = 1; idx < positions.length; idx++) { + if (positions[idx] === positions[idx - 1] + 1) { + score += 25; + } + } + return score; +} + +export async function fetchMockSuggestions(data: FetchSuggestionsData): Promise { + if (data?.suggestiontype !== "file") { + return { reqnum: data?.reqnum ?? 0, suggestions: [] }; + } + const { baseDir, queryPrefix, searchTerm } = resolveMockFileQuery(data?.["file:cwd"], data?.query ?? ""); + const fileInfos = await DefaultMockFilesystem.fileList({ + path: baseDir, + opts: { all: true, limit: MaxMockSuggestions * 4 }, + }); + const suggestions = fileInfos + .map((fileInfo, idx) => { + if (data?.["file:dironly"] && !fileInfo.isdir) { + return null; + } + const suggestionName = `${queryPrefix}${fileInfo.name}`; + const matchpos = searchTerm === "" ? [] : findMatchPositions(suggestionName, searchTerm); + if (searchTerm !== "" && matchpos == null) { + return null; + } + return { + type: "file", + suggestionid: fileInfo.path, + display: suggestionName, + "file:path": fileInfo.path, + "file:name": suggestionName, + "file:mimetype": fileInfo.mimetype, + matchpos, + score: scoreSuggestion(suggestionName, matchpos ?? [], idx), + } satisfies SuggestionType; + }) + .filter((suggestion): suggestion is SuggestionType => suggestion != null); + suggestions.sort((a, b) => { + if ((a.score ?? 0) !== (b.score ?? 0)) { + return (b.score ?? 0) - (a.score ?? 0); + } + return a.display.length - b.display.length; + }); + return { + reqnum: data?.reqnum ?? 0, + suggestions: suggestions.slice(0, MaxMockSuggestions), + }; +} diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index 5e787610e5..83076feeb1 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -11,6 +11,7 @@ import { PlatformMacOS, PlatformWindows } from "@/util/platformutil"; import { Atom, atom, PrimitiveAtom, useAtomValue } from "jotai"; import { DefaultFullConfig } from "./defaultconfig"; import { DefaultMockFilesystem } from "./mockfilesystem"; +import { fetchMockSuggestions } from "./mocksuggestions"; import { showPreviewContextMenu } from "../preview-contextmenu"; import { previewElectronApi } from "./preview-electron-api"; @@ -249,6 +250,8 @@ export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiTyp setCallHandler("fileread", async (_client, data: FileData) => DefaultMockFilesystem.fileRead(data)); setCallHandler("filelist", async (_client, data: FileListData) => DefaultMockFilesystem.fileList(data)); setCallHandler("filejoin", async (_client, data: string[]) => DefaultMockFilesystem.fileJoin(data)); + setCallHandler("fetchsuggestions", async (_client, data: FetchSuggestionsData) => fetchMockSuggestions(data)); + setCallHandler("disposesuggestions", async () => null); setStreamHandler("filereadstream", async function* (_client, data: FileData) { yield* DefaultMockFilesystem.fileReadStream(data); }); diff --git a/frontend/preview/previews/suggestion.preview.test.ts b/frontend/preview/previews/suggestion.preview.test.ts new file mode 100644 index 0000000000..3314664d28 --- /dev/null +++ b/frontend/preview/previews/suggestion.preview.test.ts @@ -0,0 +1,50 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { fetchPreviewFileSuggestions } from "@/app/view/preview/previewsuggestions"; +import { describe, expect, it, vi } from "vitest"; +import { makeMockWaveEnv } from "../mock/mockwaveenv"; + +describe("suggestion preview helpers", () => { + it("anchors file suggestions at the mock home directory without a PreviewModel", async () => { + const env = makeMockWaveEnv(); + const result = await fetchPreviewFileSuggestions(env, "", { widgetid: "widget-1", reqnum: 1 }); + + expect(result.reqnum).toBe(1); + expect(result.suggestions.some((suggestion) => suggestion["file:path"] === "/Users/mike/Documents")).toBe(true); + expect(result.suggestions.some((suggestion) => suggestion["file:path"] === "/Users/mike/Desktop")).toBe(true); + }); + + it("supports relative and absolute file queries through the mock WaveEnv rpc", async () => { + const env = makeMockWaveEnv(); + const relativeResult = await fetchPreviewFileSuggestions(env, "Documents/not", { widgetid: "widget-2", reqnum: 2 }); + const absoluteResult = await fetchPreviewFileSuggestions(env, "/Users/mike/Doc", { + widgetid: "widget-3", + reqnum: 3, + }); + + expect(relativeResult.suggestions.some((suggestion) => suggestion.display.startsWith("Documents/not"))).toBe(true); + expect( + absoluteResult.suggestions.some((suggestion) => suggestion.display.startsWith("/Users/mike/Documents")) + ).toBe(true); + }); + + it("disposes suggestions through the extracted helper", async () => { + const disposeSuggestionsCommand = vi.fn(async () => null); + const env = makeMockWaveEnv({ + rpc: { + DisposeSuggestionsCommand: disposeSuggestionsCommand, + }, + }); + + const result = await fetchPreviewFileSuggestions( + env, + "", + { widgetid: "widget-4", reqnum: 4, dispose: true }, + { cwd: "~" } + ); + + expect(result).toBeNull(); + expect(disposeSuggestionsCommand).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/preview/previews/suggestion.preview.tsx b/frontend/preview/previews/suggestion.preview.tsx new file mode 100644 index 0000000000..d02af1fd8f --- /dev/null +++ b/frontend/preview/previews/suggestion.preview.tsx @@ -0,0 +1,70 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BlockHeaderSuggestionControl } from "@/app/suggestion/suggestion"; +import { useWaveEnv } from "@/app/waveenv/waveenv"; +import { fetchPreviewFileSuggestions, PreviewSuggestionsEnv } from "@/app/view/preview/previewsuggestions"; +import { atom, useAtom } from "jotai"; +import { useCallback, useRef } from "react"; + +const SuggestionOpenAtom = atom(true); +const SelectedPathAtom = atom(""); + +export function SuggestionPreview() { + const env = useWaveEnv(); + const blockRef = useRef(null); + const [isOpen, setIsOpen] = useAtom(SuggestionOpenAtom); + const [selectedPath, setSelectedPath] = useAtom(SelectedPathAtom); + const fetchSuggestions = useCallback( + (query: string, reqContext: SuggestionRequestContext) => { + return fetchPreviewFileSuggestions(env, query, reqContext, { cwd: "~" }); + }, + [env] + ); + + return ( +
+
+ standalone file suggestions using the preview WaveEnv + mock filesystem (try: Documents/, ~/, /, rea) +
+
+
+
+
File Suggestion Control
+
Anchored at ~ when no explicit cwd is provided
+
+ +
+
+
+ Selected path:{" "} + {selectedPath === "" ? "nothing selected yet" : selectedPath} +
+
Press Tab to complete directories, or start with / to switch to an absolute-path search.
+
+
+ setIsOpen(false)} + onSelect={(suggestion, query) => { + setSelectedPath(suggestion?.["file:path"] ?? query); + return true; + }} + onTab={(suggestion) => { + if (suggestion["file:mimetype"] === "directory") { + return suggestion["file:name"] + "/"; + } + return suggestion["file:name"]; + }} + fetchSuggestions={fetchSuggestions} + placeholderText="Open File..." + /> +
+ ); +}