Skip to content
Draft
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: 5 additions & 2 deletions frontend/app/suggestion/suggestion.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -33,12 +33,15 @@ function SuggestionControl({
onTab,
fetchSuggestions,
className,
placeholderText,
children,
}: SuggestionControlProps) {
if (!isOpen || !anchorRef.current || !fetchSuggestions) return null;

return (
<SuggestionControlInner {...{ anchorRef, onClose, onSelect, onTab, fetchSuggestions, className, children }} />
<SuggestionControlInner
{...{ anchorRef, onClose, onSelect, onTab, fetchSuggestions, className, placeholderText, children }}
/>
);
}

Expand Down
40 changes: 5 additions & 35 deletions frontend/app/view/preview/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

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

const fetchSuggestions = async (
env: PreviewEnv,
model: PreviewModel,
query: string,
reqContext: SuggestionRequestContext
): Promise<FetchSuggestionsResponse> => {
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,
Expand Down Expand Up @@ -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 (
Expand Down
46 changes: 46 additions & 0 deletions frontend/app/view/preview/previewsuggestions.ts
Original file line number Diff line number Diff line change
@@ -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<FetchSuggestionsResponse> {
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 }
);
}
201 changes: 201 additions & 0 deletions frontend/preview/mock/mocksuggestions.ts
Original file line number Diff line number Diff line change
@@ -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<FetchSuggestionsResponse> {
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),
};
}
3 changes: 3 additions & 0 deletions frontend/preview/mock/mockwaveenv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
});
Expand Down
Loading