Skip to content

Commit 185fb4a

Browse files
Copilotsawka
andcommitted
Add suggestion preview
Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
1 parent cdd5a99 commit 185fb4a

7 files changed

Lines changed: 380 additions & 37 deletions

File tree

frontend/app/suggestion/suggestion.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2025, Command Line Inc.
1+
// Copyright 2026, Command Line Inc.
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { atoms } from "@/app/store/global";
@@ -33,12 +33,15 @@ function SuggestionControl({
3333
onTab,
3434
fetchSuggestions,
3535
className,
36+
placeholderText,
3637
children,
3738
}: SuggestionControlProps) {
3839
if (!isOpen || !anchorRef.current || !fetchSuggestions) return null;
3940

4041
return (
41-
<SuggestionControlInner {...{ anchorRef, onClose, onSelect, onTab, fetchSuggestions, className, children }} />
42+
<SuggestionControlInner
43+
{...{ anchorRef, onClose, onSelect, onTab, fetchSuggestions, className, placeholderText, children }}
44+
/>
4245
);
4346
}
4447

frontend/app/view/preview/preview.tsx

Lines changed: 5 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { CenteredDiv } from "@/app/element/quickelems";
5-
import { TabRpcClient } from "@/app/store/wshrpcutil";
65
import { BlockHeaderSuggestionControl } from "@/app/suggestion/suggestion";
76
import { useWaveEnv } from "@/app/waveenv/waveenv";
87
import { globalStore } from "@/store/global";
9-
import { isBlank, makeConnRoute } from "@/util/util";
8+
import { isBlank } from "@/util/util";
109
import { useAtom, useAtomValue, useSetAtom } from "jotai";
1110
import { memo, useEffect } from "react";
1211
import { CSVView } from "./csvview";
@@ -15,6 +14,7 @@ import { CodeEditPreview } from "./preview-edit";
1514
import { ErrorOverlay } from "./preview-error-overlay";
1615
import { MarkdownPreview } from "./preview-markdown";
1716
import type { PreviewModel } from "./preview-model";
17+
import { fetchPreviewFileSuggestions } from "./previewsuggestions";
1818
import { StreamingPreview } from "./preview-streaming";
1919
import type { PreviewEnv } from "./previewenv";
2020

@@ -64,38 +64,6 @@ const SpecializedView = memo(({ parentRef, model }: SpecializedViewProps) => {
6464
return <SpecializedViewComponent key={path} model={model} parentRef={parentRef} />;
6565
});
6666

67-
const fetchSuggestions = async (
68-
env: PreviewEnv,
69-
model: PreviewModel,
70-
query: string,
71-
reqContext: SuggestionRequestContext
72-
): Promise<FetchSuggestionsResponse> => {
73-
const conn = await globalStore.get(model.connection);
74-
let route = makeConnRoute(conn);
75-
if (isBlank(conn)) {
76-
route = null;
77-
}
78-
if (reqContext?.dispose) {
79-
env.rpc.DisposeSuggestionsCommand(TabRpcClient, reqContext.widgetid, { noresponse: true, route: route });
80-
return null;
81-
}
82-
const fileInfo = await globalStore.get(model.statFile);
83-
if (fileInfo == null) {
84-
return null;
85-
}
86-
const sdata = {
87-
suggestiontype: "file",
88-
"file:cwd": fileInfo.path,
89-
query: query,
90-
widgetid: reqContext.widgetid,
91-
reqnum: reqContext.reqnum,
92-
"file:connection": conn,
93-
};
94-
return await env.rpc.FetchSuggestionsCommand(TabRpcClient, sdata, {
95-
route: route,
96-
});
97-
};
98-
9967
function PreviewView({
10068
blockRef,
10169
contentRef,
@@ -143,7 +111,9 @@ function PreviewView({
143111
}
144112
};
145113
const fetchSuggestionsFn = async (query, ctx) => {
146-
return await fetchSuggestions(env, model, query, ctx);
114+
const conn = await globalStore.get(model.connection);
115+
const cwd = globalStore.get(model.statFile)?.path;
116+
return await fetchPreviewFileSuggestions(env, query, ctx, { cwd, connection: conn });
147117
};
148118

149119
return (
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { TabRpcClient } from "@/app/store/wshrpcutil";
5+
import { WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv";
6+
import { isBlank, makeConnRoute } from "@/util/util";
7+
8+
export type PreviewSuggestionsEnv = WaveEnvSubset<{
9+
rpc: {
10+
FetchSuggestionsCommand: WaveEnv["rpc"]["FetchSuggestionsCommand"];
11+
DisposeSuggestionsCommand: WaveEnv["rpc"]["DisposeSuggestionsCommand"];
12+
};
13+
}>;
14+
15+
type FetchPreviewFileSuggestionsOpts = {
16+
cwd?: string;
17+
connection?: string;
18+
};
19+
20+
export async function fetchPreviewFileSuggestions(
21+
env: PreviewSuggestionsEnv,
22+
query: string,
23+
reqContext: SuggestionRequestContext,
24+
opts?: FetchPreviewFileSuggestionsOpts
25+
): Promise<FetchSuggestionsResponse> {
26+
let route = makeConnRoute(opts?.connection);
27+
if (isBlank(opts?.connection)) {
28+
route = null;
29+
}
30+
if (reqContext?.dispose) {
31+
env.rpc.DisposeSuggestionsCommand(TabRpcClient, reqContext.widgetid, { noresponse: true, route });
32+
return null;
33+
}
34+
return await env.rpc.FetchSuggestionsCommand(
35+
TabRpcClient,
36+
{
37+
suggestiontype: "file",
38+
"file:cwd": opts?.cwd ?? "~",
39+
query,
40+
widgetid: reqContext.widgetid,
41+
reqnum: reqContext.reqnum,
42+
"file:connection": opts?.connection,
43+
},
44+
{ route }
45+
);
46+
}
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { DefaultMockFilesystem } from "./mockfilesystem";
5+
6+
const MaxMockSuggestions = 50;
7+
8+
type ResolvedMockFileQuery = {
9+
baseDir: string;
10+
queryPrefix: string;
11+
searchTerm: string;
12+
};
13+
14+
function ensureTrailingSlash(path: string): string {
15+
if (path === "" || path.endsWith("/")) {
16+
return path;
17+
}
18+
return path + "/";
19+
}
20+
21+
function trimTrailingSlash(path: string): string {
22+
if (path === "/") {
23+
return path;
24+
}
25+
return path.replace(/\/+$/, "");
26+
}
27+
28+
function getDirName(path: string): string {
29+
const trimmedPath = trimTrailingSlash(path);
30+
if (trimmedPath === "/") {
31+
return "/";
32+
}
33+
const idx = trimmedPath.lastIndexOf("/");
34+
if (idx <= 0) {
35+
return "/";
36+
}
37+
return trimmedPath.slice(0, idx);
38+
}
39+
40+
function getBaseName(path: string): string {
41+
const trimmedPath = trimTrailingSlash(path);
42+
if (trimmedPath === "/") {
43+
return "/";
44+
}
45+
const idx = trimmedPath.lastIndexOf("/");
46+
return idx < 0 ? trimmedPath : trimmedPath.slice(idx + 1);
47+
}
48+
49+
function expandMockHome(path: string): string {
50+
if (path === "~") {
51+
return DefaultMockFilesystem.homePath;
52+
}
53+
if (path.startsWith("~/")) {
54+
return DefaultMockFilesystem.homePath + path.slice(1);
55+
}
56+
return path;
57+
}
58+
59+
function normalizeMockPath(path: string, basePath = DefaultMockFilesystem.homePath): string {
60+
if (path == null || path === "") {
61+
return basePath;
62+
}
63+
path = expandMockHome(path);
64+
if (!path.startsWith("/")) {
65+
path = `${basePath}/${path}`;
66+
}
67+
const resolvedParts: string[] = [];
68+
for (const part of path.split("/")) {
69+
if (part === "" || part === ".") {
70+
continue;
71+
}
72+
if (part === "..") {
73+
resolvedParts.pop();
74+
continue;
75+
}
76+
resolvedParts.push(part);
77+
}
78+
return "/" + resolvedParts.join("/");
79+
}
80+
81+
function resolveMockFileQuery(cwd: string, query: string): ResolvedMockFileQuery {
82+
const resolvedCwd = normalizeMockPath(cwd || "~", "/");
83+
if (query == null || query === "") {
84+
return { baseDir: resolvedCwd, queryPrefix: "", searchTerm: "" };
85+
}
86+
if (query === "~" || query === "~/") {
87+
return { baseDir: DefaultMockFilesystem.homePath, queryPrefix: "~/", searchTerm: "" };
88+
}
89+
const expandedQuery = expandMockHome(query);
90+
if (expandedQuery.startsWith("/")) {
91+
if (query.endsWith("/")) {
92+
return {
93+
baseDir: normalizeMockPath(expandedQuery, "/"),
94+
queryPrefix: query,
95+
searchTerm: "",
96+
};
97+
}
98+
if (expandedQuery === "/") {
99+
return { baseDir: "/", queryPrefix: "/", searchTerm: "" };
100+
}
101+
return {
102+
baseDir: getDirName(expandedQuery),
103+
queryPrefix: ensureTrailingSlash(getDirName(query)),
104+
searchTerm: getBaseName(expandedQuery),
105+
};
106+
}
107+
if (query.endsWith("/")) {
108+
return {
109+
baseDir: normalizeMockPath(query, resolvedCwd),
110+
queryPrefix: query,
111+
searchTerm: "",
112+
};
113+
}
114+
const slashIdx = query.lastIndexOf("/");
115+
if (slashIdx !== -1) {
116+
const dirPart = query.slice(0, slashIdx);
117+
return {
118+
baseDir: normalizeMockPath(dirPart, resolvedCwd),
119+
queryPrefix: ensureTrailingSlash(dirPart),
120+
searchTerm: query.slice(slashIdx + 1),
121+
};
122+
}
123+
return { baseDir: resolvedCwd, queryPrefix: "", searchTerm: query };
124+
}
125+
126+
function findMatchPositions(value: string, searchTerm: string): number[] {
127+
const lowerValue = value.toLowerCase();
128+
const lowerSearchTerm = searchTerm.toLowerCase();
129+
const positions: number[] = [];
130+
let searchIdx = 0;
131+
for (let idx = 0; idx < lowerValue.length; idx++) {
132+
if (lowerValue[idx] !== lowerSearchTerm[searchIdx]) {
133+
continue;
134+
}
135+
positions.push(idx);
136+
searchIdx++;
137+
if (searchIdx >= lowerSearchTerm.length) {
138+
return positions;
139+
}
140+
}
141+
return null;
142+
}
143+
144+
function scoreSuggestion(value: string, positions: number[], fallbackIndex: number): number {
145+
if (positions.length === 0) {
146+
return MaxMockSuggestions - fallbackIndex;
147+
}
148+
let score = 1000 - value.length;
149+
if (positions[0] === 0) {
150+
score += 500;
151+
}
152+
for (let idx = 1; idx < positions.length; idx++) {
153+
if (positions[idx] === positions[idx - 1] + 1) {
154+
score += 25;
155+
}
156+
}
157+
return score;
158+
}
159+
160+
export async function fetchMockSuggestions(data: FetchSuggestionsData): Promise<FetchSuggestionsResponse> {
161+
if (data?.suggestiontype !== "file") {
162+
return { reqnum: data?.reqnum ?? 0, suggestions: [] };
163+
}
164+
const { baseDir, queryPrefix, searchTerm } = resolveMockFileQuery(data?.["file:cwd"], data?.query ?? "");
165+
const fileInfos = await DefaultMockFilesystem.fileList({
166+
path: baseDir,
167+
opts: { all: true, limit: MaxMockSuggestions * 4 },
168+
});
169+
const suggestions = fileInfos
170+
.map((fileInfo, idx) => {
171+
if (data?.["file:dironly"] && !fileInfo.isdir) {
172+
return null;
173+
}
174+
const suggestionName = `${queryPrefix}${fileInfo.name}`;
175+
const matchpos = searchTerm === "" ? [] : findMatchPositions(suggestionName, searchTerm);
176+
if (searchTerm !== "" && matchpos == null) {
177+
return null;
178+
}
179+
return {
180+
type: "file",
181+
suggestionid: fileInfo.path,
182+
display: suggestionName,
183+
"file:path": fileInfo.path,
184+
"file:name": suggestionName,
185+
"file:mimetype": fileInfo.mimetype,
186+
matchpos,
187+
score: scoreSuggestion(suggestionName, matchpos ?? [], idx),
188+
} satisfies SuggestionType;
189+
})
190+
.filter((suggestion): suggestion is SuggestionType => suggestion != null);
191+
suggestions.sort((a, b) => {
192+
if ((a.score ?? 0) !== (b.score ?? 0)) {
193+
return (b.score ?? 0) - (a.score ?? 0);
194+
}
195+
return a.display.length - b.display.length;
196+
});
197+
return {
198+
reqnum: data?.reqnum ?? 0,
199+
suggestions: suggestions.slice(0, MaxMockSuggestions),
200+
};
201+
}

frontend/preview/mock/mockwaveenv.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { PlatformMacOS, PlatformWindows } from "@/util/platformutil";
1111
import { Atom, atom, PrimitiveAtom, useAtomValue } from "jotai";
1212
import { DefaultFullConfig } from "./defaultconfig";
1313
import { DefaultMockFilesystem } from "./mockfilesystem";
14+
import { fetchMockSuggestions } from "./mocksuggestions";
1415
import { showPreviewContextMenu } from "../preview-contextmenu";
1516
import { previewElectronApi } from "./preview-electron-api";
1617

@@ -249,6 +250,8 @@ export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiTyp
249250
setCallHandler("fileread", async (_client, data: FileData) => DefaultMockFilesystem.fileRead(data));
250251
setCallHandler("filelist", async (_client, data: FileListData) => DefaultMockFilesystem.fileList(data));
251252
setCallHandler("filejoin", async (_client, data: string[]) => DefaultMockFilesystem.fileJoin(data));
253+
setCallHandler("fetchsuggestions", async (_client, data: FetchSuggestionsData) => fetchMockSuggestions(data));
254+
setCallHandler("disposesuggestions", async () => null);
252255
setStreamHandler("filereadstream", async function* (_client, data: FileData) {
253256
yield* DefaultMockFilesystem.fileReadStream(data);
254257
});

0 commit comments

Comments
 (0)