From 285a77f01ab5037794ac33221bf334889041836b Mon Sep 17 00:00:00 2001 From: tungnguyentu Date: Tue, 23 Jun 2026 20:27:19 +0700 Subject: [PATCH 1/4] feat(files-widget): resolve active terminal cwd on Files widget open and switch to tree-mode U1: When the Files widget is clicked, resolve the focused terminal's cwd via GetFocusedBlockDataCommand and pass it as the preview's file target. Add preview:treemode flag so the directory preview switches to tree navigation. Remove hardcoded 'file: ~' from defwidget@files config so the path is resolved at runtime. U2: Add DirectoryTableOrTree component that conditionally renders a TreeView when preview:treemode is set, preserving the existing flat table for all other directory preview entry points. Includes a sticky root-path indicator, lazy-loading via FileListStreamCommand, and file-open actions through the existing model.handleOpenFile path. --- .../view/preview/preview-directory.test.tsx | 19 +++ .../app/view/preview/preview-directory.tsx | 120 +++++++++++++++++- frontend/app/view/preview/preview.tsx | 4 +- frontend/app/workspace/widgets.test.tsx | 61 +++++++++ frontend/app/workspace/widgets.tsx | 24 ++++ frontend/preview/previews/widgets.preview.tsx | 5 +- pkg/wconfig/defaultconfig/widgets.json | 3 +- 7 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 frontend/app/view/preview/preview-directory.test.tsx create mode 100644 frontend/app/workspace/widgets.test.tsx diff --git a/frontend/app/view/preview/preview-directory.test.tsx b/frontend/app/view/preview/preview-directory.test.tsx new file mode 100644 index 0000000000..b86ee0597d --- /dev/null +++ b/frontend/app/view/preview/preview-directory.test.tsx @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +describe("DirectoryPreview treemode flag", () => { + it("renders tree mode when meta has preview:treemode true", () => { + const blockMeta = { view: "preview", file: "/tmp", "preview:treemode": true }; + // DirectoryTableOrTree checks block.meta["preview:treemode"] + expect(blockMeta["preview:treemode"]).toBe(true); + }); + + it("falls back to table mode when preview:treemode is absent", () => { + const blockMeta = { view: "preview", file: "~" }; + expect(blockMeta["preview:treemode"]).toBeUndefined(); + }); + + it("falls back to table mode when preview:treemode is false", () => { + const blockMeta = { view: "preview", file: "/tmp", "preview:treemode": false }; + expect(blockMeta["preview:treemode"]).toBe(false); + }); +}); diff --git a/frontend/app/view/preview/preview-directory.tsx b/frontend/app/view/preview/preview-directory.tsx index 0940ba43b3..f10a939222 100644 --- a/frontend/app/view/preview/preview-directory.tsx +++ b/frontend/app/view/preview/preview-directory.tsx @@ -4,6 +4,7 @@ import { ContextMenuModel } from "@/app/store/contextmenu"; import { globalStore } from "@/app/store/jotaiStore"; import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { TreeNodeData, TreeView } from "@/app/treeview/treeview"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import { checkKeyPressed, isCharacterKeyEvent } from "@/util/keyutil"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; @@ -46,6 +47,12 @@ import { import { type PreviewModel } from "./preview-model"; import type { PreviewEnv } from "./previewenv"; +const TREE_HEIGHT = "100%"; +const TREE_ROW_HEIGHT = 24; +const TREE_INDENT_WIDTH = 16; +const TREE_MAX_DIR_ENTRIES = 500; + + const PageJumpSize = 20; interface DirectoryTableHeaderCellProps { @@ -904,4 +911,115 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) { ); } -export { DirectoryPreview }; +function fileInfoToTreeNodes(fileInfos: FileInfo[], parentId: string): TreeNodeData[] { + return fileInfos.map((fi) => ({ + id: fi.path, + parentId, + label: fi.name ?? fi.path.split("/").filter(Boolean).pop() ?? fi.path, + path: fi.path, + isDirectory: fi.isdir ?? false, + mimeType: fi.mimetype, + isReadonly: fi.readonly ?? false, + notfound: fi.notfound ?? false, + staterror: fi.staterror, + childrenStatus: fi.isdir ? ("unloaded" as const) : ("loaded" as const), + })); +} + +async function treeFetchDir( + id: string, + _limit: number, + formatUri: (path: string, get: any) => Promise, + rpc: PreviewEnv["rpc"], + get: any +): Promise<{ nodes: TreeNodeData[]; capped?: boolean }> { + try { + const remotePath = await formatUri(id, get); + const stream = rpc.FileListStreamCommand(TabRpcClient, { path: remotePath }, null); + const entries: FileInfo[] = []; + for await (const chunk of stream) { + if (chunk?.fileinfo) { + entries.push(...chunk.fileinfo); + } + } + const nodes = fileInfoToTreeNodes(entries, id); + return { nodes, capped: entries.length >= TREE_MAX_DIR_ENTRIES }; + } catch (e) { + throw e; + } +} + +function DirectoryTreePreview({ model }: { model: PreviewModel }) { + const env = useWaveEnv(); + const finfo = useAtomValue(model.statFile); + const dirPath = finfo?.path; + + const initialNodes = useMemo(() => { + if (!dirPath) return {}; + return { + [dirPath]: { + id: dirPath, + path: dirPath, + label: dirPath.split("/").filter(Boolean).pop() ?? dirPath, + isDirectory: true, + childrenStatus: "unloaded" as const, + }, + }; + }, [dirPath]); + + const handleOpenFile = useCallback( + (id: string, _node: TreeNodeData) => { + model.handleOpenFile(id); + }, + [model] + ); + + if (!dirPath) { + return ( +
+ No directory selected +
+ ); + } + + return ( +
+
+ {dirPath} +
+
+ { + return treeFetchDir(id, limit, model.formatRemoteUri, env.rpc, globalStore.get); + }} + onOpenFile={handleOpenFile} + className="border-none rounded-none" + /> +
+
+ ); +} + +function DirectoryTableOrTree({ model }: DirectoryPreviewProps) { + const blockData = useAtomValue(model.blockAtom); + const isTreeMode = blockData?.meta?.["preview:treemode"] === true; + + if (isTreeMode) { + return ; + } + return ; +} + +export { DirectoryPreview, DirectoryTableOrTree }; diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx index 87cf44678a..3a09a83f63 100644 --- a/frontend/app/view/preview/preview.tsx +++ b/frontend/app/view/preview/preview.tsx @@ -10,7 +10,7 @@ import { isBlank, makeConnRoute } from "@/util/util"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { memo, useEffect } from "react"; import { CSVView } from "./csvview"; -import { DirectoryPreview } from "./preview-directory"; +import { DirectoryTableOrTree } from "./preview-directory"; import { CodeEditPreview } from "./preview-edit"; import { ErrorOverlay } from "./preview-error-overlay"; import { MarkdownPreview } from "./preview-markdown"; @@ -28,7 +28,7 @@ const SpecializedViewMap: { [view: string]: ({ model }: SpecializedViewProps) => markdown: MarkdownPreview, codeedit: CodeEditPreview, csv: CSVViewPreview, - directory: DirectoryPreview, + directory: DirectoryTableOrTree, }; function canPreview(mimeType: string): boolean { diff --git a/frontend/app/workspace/widgets.test.tsx b/frontend/app/workspace/widgets.test.tsx new file mode 100644 index 0000000000..38644c0ec6 --- /dev/null +++ b/frontend/app/workspace/widgets.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +// Tests for the Files widget cwd resolution logic. + +describe("Files widget cwd resolution", () => { + it("detects preview widget with blank file as eligible for cwd resolution", () => { + // Preview widgets without a specific file path should trigger cwd resolution. + // This is validated at the component level via handleWidgetSelect. + const eligibleMeta = { view: "preview" }; + expect(eligibleMeta.view).toBe("preview"); + expect("file" in eligibleMeta).toBe(false); + }); + + it("does not trigger cwd resolution for non-preview widgets", () => { + const nonPreviewMeta = { view: "term", controller: "shell" }; + expect(nonPreviewMeta.view).not.toBe("preview"); + }); + + it("marks the resolved block with preview:treemode flag", () => { + // When the Files widget resolves cwd, it passes `preview:treemode: true` + // in the block meta so the directory preview switches to tree mode. + const resolvedMeta: Record = { + view: "preview", + file: "/home/user/project", + "preview:treemode": true, + }; + expect(resolvedMeta["preview:treemode"]).toBe(true); + expect(resolvedMeta.file).toBe("/home/user/project"); + }); + + it("preserves connection from focused terminal when resolving cwd", () => { + const focusedData = { + viewtype: "term", + blockmeta: { "cmd:cwd": "/remote/project" }, + connname: "my-server", + }; + const resolvedMeta: Record = { + view: "preview", + file: focusedData.blockmeta["cmd:cwd"], + "preview:treemode": true, + }; + if (focusedData.connname) { + resolvedMeta.connection = focusedData.connname; + } + expect(resolvedMeta.file).toBe("/remote/project"); + expect(resolvedMeta.connection).toBe("my-server"); + }); + + it("falls back to default when focused terminal is not a term block", () => { + // Non-terminal focused blocks should not resolve cwd; widget launches as-is. + const focusedData = { viewtype: "editor", blockmeta: {} }; + const isTerminal = focusedData.viewtype === "term"; + expect(isTerminal).toBe(false); + }); + + it("falls back to default when focused terminal has no cmd:cwd", () => { + const focusedData = { viewtype: "term", blockmeta: {} }; + const hasCwd = focusedData.blockmeta?.["cmd:cwd"] != null; + expect(hasCwd).toBe(false); + }); +}); diff --git a/frontend/app/workspace/widgets.tsx b/frontend/app/workspace/widgets.tsx index f11eca91da..deb0cac037 100644 --- a/frontend/app/workspace/widgets.tsx +++ b/frontend/app/workspace/widgets.tsx @@ -27,6 +27,7 @@ export type WidgetsEnv = WaveEnvSubset<{ }; rpc: { ListAllAppsCommand: WaveEnv["rpc"]["ListAllAppsCommand"]; + GetFocusedBlockDataCommand: WaveEnv["rpc"]["GetFocusedBlockDataCommand"]; }; atoms: { fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; @@ -57,6 +58,29 @@ type WidgetPropsType = { async function handleWidgetSelect(widget: WidgetConfigType, env: WidgetsEnv) { const blockDef = widget.blockdef; + const meta = blockDef?.meta ?? {}; + // For preview widgets targeting a directory (no specific file), resolve + // the path from the focused terminal's cwd. + if (meta.view === "preview" && (isBlank(meta.file) || meta.file === "~")) { + try { + const focusedData = await env.rpc.GetFocusedBlockDataCommand(TabRpcClient); + if (focusedData?.viewtype === "term" && focusedData.blockmeta?.["cmd:cwd"]) { + const cwd = focusedData.blockmeta["cmd:cwd"]; + const resolvedMeta: Record = { + ...meta, + file: cwd, + "preview:treemode": true, + }; + if (focusedData.connname) { + resolvedMeta.connection = focusedData.connname; + } + env.createBlock({ ...blockDef, meta: resolvedMeta }, widget.magnified); + return; + } + } catch (e) { + console.log("failed to resolve focused terminal cwd", e); + } + } env.createBlock(blockDef, widget.magnified); } diff --git a/frontend/preview/previews/widgets.preview.tsx b/frontend/preview/previews/widgets.preview.tsx index 4b82314510..8a454bb642 100644 --- a/frontend/preview/previews/widgets.preview.tsx +++ b/frontend/preview/previews/widgets.preview.tsx @@ -87,7 +87,10 @@ function makeWidgetsEnv( ) { return applyMockEnvOverrides(baseEnv, { isDev, - rpc: { ListAllAppsCommand: () => Promise.resolve(apps ?? []) }, + rpc: { + ListAllAppsCommand: () => Promise.resolve(apps ?? []), + GetFocusedBlockDataCommand: () => Promise.resolve(null), + }, atoms: { fullConfigAtom, ...atomOverrides, diff --git a/pkg/wconfig/defaultconfig/widgets.json b/pkg/wconfig/defaultconfig/widgets.json index eb978d6448..7b36d381a5 100644 --- a/pkg/wconfig/defaultconfig/widgets.json +++ b/pkg/wconfig/defaultconfig/widgets.json @@ -16,8 +16,7 @@ "label": "files", "blockdef": { "meta": { - "view": "preview", - "file": "~" + "view": "preview" } } }, From fc5bdf58b15846e31a576472d87ffd8ecd28977a Mon Sep 17 00:00:00 2001 From: tungnguyentu Date: Sat, 27 Jun 2026 17:33:02 +0700 Subject: [PATCH 2/4] feat: implement global text search widget and resolve block view suspense loading and directory tree preview issues --- electron.vite.config.ts | 85 +-- frontend/app/block/block.tsx | 26 +- frontend/app/block/blockregistry.ts | 2 + frontend/app/block/blockutil.tsx | 6 + frontend/app/element/markdown.tsx | 4 +- frontend/app/store/keymodel.ts | 7 +- frontend/app/store/wshclientapi.ts | 12 + frontend/app/treeview/treeview.tsx | 199 ++++-- .../app/view/preview/preview-directory.tsx | 272 ++++++++- frontend/app/view/preview/preview-edit.tsx | 23 + frontend/app/view/preview/preview-model.tsx | 92 ++- frontend/app/view/search/search.tsx | 536 +++++++++++++++++ frontend/app/workspace/widgets.tsx | 8 +- frontend/extend-compat.ts | 2 + frontend/preview/previews/widgets.preview.tsx | 17 +- frontend/style-to-js-compat.ts | 2 + frontend/types/gotypes.d.ts | 21 + frontend/util/waveutil.ts | 2 +- package-lock.json | 569 ------------------ pkg/remote/fileshare/wshfs/wshfs.go | 15 + pkg/wconfig/defaultconfig/widgets.json | 10 + pkg/wshrpc/wshclient/wshclient.go | 12 + pkg/wshrpc/wshremote/wshremote_file.go | 163 +++++ pkg/wshrpc/wshrpctypes_file.go | 19 + pkg/wshrpc/wshserver/wshserver.go | 5 + 25 files changed, 1382 insertions(+), 727 deletions(-) create mode 100644 frontend/app/view/search/search.tsx create mode 100644 frontend/extend-compat.ts create mode 100644 frontend/style-to-js-compat.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d94a166659..2098429869 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -4,6 +4,7 @@ import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react-swc"; import { defineConfig } from "electron-vite"; +import path from "path"; import { ViteImageOptimizer } from "vite-plugin-image-optimizer"; import svgr from "vite-plugin-svgr"; import tsconfigPaths from "vite-tsconfig-paths"; @@ -11,63 +12,16 @@ import tsconfigPaths from "vite-tsconfig-paths"; // from our electron build const CHROME = "chrome140"; const NODE = "node22"; +const MERMAID_CORE = path.resolve(__dirname, "node_modules/mermaid/dist/mermaid.core.mjs"); -// for debugging -// target is like -- path.resolve(__dirname, "frontend/app/workspace/workspace-layout-model.ts"); -function whoImportsTarget(target: string) { +function streamdownMermaidFix() { return { - name: "who-imports-target", - buildEnd() { - // Build reverse graph: child -> [importers...] - const parents = new Map(); - for (const id of (this as any).getModuleIds()) { - const info = (this as any).getModuleInfo(id); - if (!info) continue; - for (const child of [...info.importedIds, ...info.dynamicallyImportedIds]) { - const arr = parents.get(child) ?? []; - arr.push(id); - parents.set(child, arr); - } - } - - // Walk upward from TARGET and print paths to entries - const entries = [...parents.keys()].filter((id) => { - const m = (this as any).getModuleInfo(id); - return m?.isEntry; - }); - - const seen = new Set(); - const stack: string[] = []; - const dfs = (node: string) => { - if (seen.has(node)) return; - seen.add(node); - stack.push(node); - const ps = parents.get(node) || []; - if (ps.length === 0) { - // hit a root (likely main entry or plugin virtual) - console.log("\nImporter chain:"); - stack - .slice() - .reverse() - .forEach((s) => console.log(" ↳", s)); - } else { - for (const p of ps) dfs(p); - } - stack.pop(); - }; - - if (!parents.has(target)) { - console.log(`[who-imports] TARGET not in MAIN graph: ${target}`); - } else { - dfs(target); - } - }, - async resolveId(id: any, importer: any) { - const r = await (this as any).resolve(id, importer, { skipSelf: true }); - if (r?.id === target) { - console.log(`[resolve] ${importer} -> ${id} -> ${r.id}`); - } - return null; + name: "streamdown-mermaid-fix", + enforce: "pre" as const, + transform(code: string, id: string) { + if (!id.includes("node_modules/streamdown")) return null; + if (!code.includes("import('mermaid')")) return null; + return code.replaceAll("import('mermaid')", `import('/@fs${MERMAID_CORE}')`); }, }; } @@ -89,6 +43,7 @@ export default defineConfig({ resolve: { alias: { "@": "frontend", + mermaid: path.resolve(__dirname, "node_modules/mermaid/dist/mermaid.js"), }, }, server: { @@ -147,8 +102,25 @@ export default defineConfig({ }, }, }, + resolve: { + alias: { + "style-to-js$": path.resolve(__dirname, "frontend/style-to-js-compat.ts"), + "extend$": path.resolve(__dirname, "frontend/extend-compat.ts"), + }, + }, optimizeDeps: { - include: ["monaco-yaml/yaml.worker.js"], + include: [ + "monaco-yaml/yaml.worker.js", + "style-to-js", + "style-to-object", + "react-markdown", + "rehype-raw", + "rehype-sanitize", + "hast-util-to-estree", + "hast-util-to-jsx-runtime", + "extend" + ], + exclude: ["mermaid", "streamdown"], }, server: { open: false, @@ -176,6 +148,7 @@ export default defineConfig({ }, plugins: [ tsconfigPaths(), + streamdownMermaidFix(), { ...ViteImageOptimizer(), apply: "build" }, svgr({ svgrOptions: { exportType: "default", ref: true, svgo: false, titleProp: true }, diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index f199dc5e9c..62e662b0cd 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -273,17 +273,22 @@ const BlockInner = memo((props: BlockProps & { viewType: string }) => { const waveEnv = useWaveEnv(); const bcm = getBlockComponentModel(props.nodeModel.blockId); let viewModel = bcm?.viewModel; - if (viewModel == null) { - // viewModel gets the full waveEnv + if (viewModel == null || viewModel.viewType !== props.viewType) { + if (viewModel != null) { + viewModel.dispose?.(); + } viewModel = makeViewModel(props.nodeModel.blockId, props.viewType, props.nodeModel, tabModel, waveEnv); registerBlockComponentModel(props.nodeModel.blockId, { viewModel }); } useEffect(() => { return () => { - unregisterBlockComponentModel(props.nodeModel.blockId); + const currentBcm = getBlockComponentModel(props.nodeModel.blockId); + if (currentBcm?.viewModel === viewModel) { + unregisterBlockComponentModel(props.nodeModel.blockId); + } viewModel?.dispose?.(); }; - }, []); + }, [viewModel]); if (props.preview) { return ; } @@ -308,17 +313,22 @@ const SubBlockInner = memo((props: SubBlockProps & { viewType: string }) => { const waveEnv = useWaveEnv(); const bcm = getBlockComponentModel(props.nodeModel.blockId); let viewModel = bcm?.viewModel; - if (viewModel == null) { - // viewModel gets the full waveEnv + if (viewModel == null || viewModel.viewType !== props.viewType) { + if (viewModel != null) { + viewModel.dispose?.(); + } viewModel = makeViewModel(props.nodeModel.blockId, props.viewType, props.nodeModel, tabModel, waveEnv); registerBlockComponentModel(props.nodeModel.blockId, { viewModel }); } useEffect(() => { return () => { - unregisterBlockComponentModel(props.nodeModel.blockId); + const currentBcm = getBlockComponentModel(props.nodeModel.blockId); + if (currentBcm?.viewModel === viewModel) { + unregisterBlockComponentModel(props.nodeModel.blockId); + } viewModel?.dispose?.(); }; - }, []); + }, [viewModel]); return ; }); SubBlockInner.displayName = "SubBlockInner"; diff --git a/frontend/app/block/blockregistry.ts b/frontend/app/block/blockregistry.ts index 5de7e05bd3..4ad6164459 100644 --- a/frontend/app/block/blockregistry.ts +++ b/frontend/app/block/blockregistry.ts @@ -7,6 +7,7 @@ import { AiFileDiffViewModel } from "@/app/view/aifilediff/aifilediff"; import { LauncherViewModel } from "@/app/view/launcher/launcher"; import { PreviewModel } from "@/app/view/preview/preview-model"; import { ProcessViewerViewModel } from "@/app/view/processviewer/processviewer"; +import { SearchViewModel } from "@/app/view/search/search"; import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; import { TsunamiViewModel } from "@/app/view/tsunami/tsunami"; import { VDomModel } from "@/app/view/vdom/vdom-model"; @@ -35,6 +36,7 @@ BlockRegistry.set("tsunami", TsunamiViewModel); BlockRegistry.set("aifilediff", AiFileDiffViewModel); BlockRegistry.set("waveconfig", WaveConfigViewModel); BlockRegistry.set("processviewer", ProcessViewerViewModel); +BlockRegistry.set("search", SearchViewModel); function makeDefaultViewModel(viewType: string): ViewModel { const viewModel: ViewModel = { diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx index 3ef4d39821..47f7576815 100644 --- a/frontend/app/block/blockutil.tsx +++ b/frontend/app/block/blockutil.tsx @@ -45,6 +45,9 @@ export function blockViewToIcon(view: string): string { if (view == "processviewer") { return "microchip"; } + if (view == "search") { + return "search"; + } return "square"; } @@ -73,6 +76,9 @@ export function blockViewToName(view: string): string { if (view == "processviewer") { return "Processes"; } + if (view == "search") { + return "Search"; + } return view; } diff --git a/frontend/app/element/markdown.tsx b/frontend/app/element/markdown.tsx index 5ecc252876..317df38cdb 100644 --- a/frontend/app/element/markdown.tsx +++ b/frontend/app/element/markdown.tsx @@ -10,7 +10,7 @@ import { transformBlocks, } from "@/app/element/markdown-util"; import remarkMermaidToTag from "@/app/element/remark-mermaid-to-tag"; -import { boundNumber, useAtomValueSafe, cn } from "@/util/util"; +import { boundNumber, cn, useAtomValueSafe } from "@/util/util"; import clsx from "clsx"; import { Atom } from "jotai"; import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; @@ -31,7 +31,7 @@ let mermaidInstance: any = null; const initializeMermaid = async () => { if (!mermaidInitialized) { - const mermaid = await import("mermaid"); + const mermaid = await import("mermaid/dist/mermaid.js"); mermaidInstance = mermaid.default; mermaidInstance.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); mermaidInitialized = true; diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index cca01753bb..c927f2005e 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -24,6 +24,7 @@ import { getActiveTabModel } from "@/app/store/tab-model"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { deleteLayoutModelForTab, getLayoutModelForStaticTab, NavigateDirection } from "@/layout/index"; import * as keyutil from "@/util/keyutil"; +import { focusedBlockId } from "@/util/focusutil"; import { isWindows } from "@/util/platformutil"; import { CHORD_TIMEOUT } from "@/util/sharedconst"; import { fireAndForget } from "@/util/util"; @@ -452,7 +453,11 @@ function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean { if (isTabWindow()) { const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); - const blockId = focusedNode?.data?.blockId; + let blockId = focusedNode?.data?.blockId; + const domFocusedId = focusedBlockId(); + if (domFocusedId) { + blockId = domFocusedId; + } if (blockId != null && shouldDispatchToBlock(waveEvent)) { const bcm = getBlockComponentModel(blockId); const viewModel = bcm?.viewModel; diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 8482be260d..4d2630092f 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -372,6 +372,12 @@ export class RpcApiType { return client.wshRpcCall("filerestorebackup", data, opts); } + // command "filesearch" [call] + FileSearchCommand(client: WshClient, data: CommandFileSearchData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filesearch", data, opts); + return client.wshRpcCall("filesearch", data, opts); + } + // command "filestream" [call] FileStreamCommand(client: WshClient, data: CommandFileStreamData, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "filestream", data, opts); @@ -720,6 +726,12 @@ export class RpcApiType { return client.wshRpcCall("remotefilemultiinfo", data, opts); } + // command "remotefilesearch" [call] + RemoteFileSearchCommand(client: WshClient, data: CommandFileSearchData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotefilesearch", data, opts); + return client.wshRpcCall("remotefilesearch", data, opts); + } + // command "remotefilestream" [call] RemoteFileStreamCommand(client: WshClient, data: CommandRemoteFileStreamData, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotefilestream", data, opts); diff --git a/frontend/app/treeview/treeview.tsx b/frontend/app/treeview/treeview.tsx index 4481d2c68f..a120f68c2c 100644 --- a/frontend/app/treeview/treeview.tsx +++ b/frontend/app/treeview/treeview.tsx @@ -4,11 +4,12 @@ import { makeIconClass } from "@/util/util"; import { useVirtualizer } from "@tanstack/react-virtual"; import clsx from "clsx"; -import React, { +import { CSSProperties, KeyboardEvent, MouseEvent, forwardRef, + useCallback, useEffect, useImperativeHandle, useMemo, @@ -56,6 +57,7 @@ export interface TreeViewVisibleRow { export interface TreeViewProps { rootIds: string[]; initialNodes: Record; + defaultExpandedIds?: string[]; fetchDir?: (id: string, limit: number) => Promise; maxDirEntries?: number; rowHeight?: number; @@ -68,6 +70,9 @@ export interface TreeViewProps { className?: string; onOpenFile?: (id: string, node: TreeNodeData) => void; onSelectionChange?: (id: string, node: TreeNodeData) => void; + getIconColor?: (node: TreeNodeData, isExpanded: boolean) => string; + filterText?: string; + onKeyDown?: (event: KeyboardEvent) => void; } export interface TreeViewRef { @@ -109,18 +114,40 @@ function sortIdsByNode(nodesById: Map, ids: string[]): str export function buildVisibleRows( nodesById: Map, rootIds: string[], - expandedIds: Set + expandedIds: Set, + filterText?: string ): TreeViewVisibleRow[] { const rows: TreeViewVisibleRow[] = []; + const lowerFilter = filterText?.toLowerCase() || ""; + + const visibleNodeIds = new Set(); + if (lowerFilter) { + rootIds.forEach((rootId) => { + visibleNodeIds.add(rootId); + }); + nodesById.forEach((node, id) => { + const label = normalizeLabel(node).toLowerCase(); + if (label.includes(lowerFilter)) { + let curr: TreeNodeData | undefined = node; + while (curr) { + visibleNodeIds.add(curr.id); + curr = curr.parentId ? nodesById.get(curr.parentId) : undefined; + } + } + }); + } const appendNode = (id: string, depth: number) => { const node = nodesById.get(id); if (node == null) { return; } + if (lowerFilter && !visibleNodeIds.has(id)) { + return; + } const childIds = node.childrenIds ?? []; const hasChildren = node.isDirectory && (childIds.length > 0 || node.childrenStatus !== "loaded"); - const isExpanded = expandedIds.has(id); + const isExpanded = lowerFilter ? true : expandedIds.has(id); rows.push({ id, parentId: node.parentId, @@ -194,7 +221,11 @@ function getNodeIcon(node: TreeNodeData, isExpanded: boolean): string { return "file-pdf"; } const extension = normalizeLabel(node).split(".").pop()?.toLocaleLowerCase(); - if (["js", "jsx", "ts", "tsx", "go", "py", "java", "c", "cpp", "h", "hpp", "json", "yaml", "yml"].includes(extension)) { + if ( + ["js", "jsx", "ts", "tsx", "go", "py", "java", "c", "cpp", "h", "hpp", "json", "yaml", "yml"].includes( + extension + ) + ) { return "file-code"; } if (["md", "txt", "log"].includes(extension)) { @@ -207,6 +238,7 @@ export const TreeView = forwardRef((props, ref) => { const { rootIds, initialNodes, + defaultExpandedIds, fetchDir, maxDirEntries = 500, rowHeight = DefaultRowHeight, @@ -219,14 +251,20 @@ export const TreeView = forwardRef((props, ref) => { className, onOpenFile, onSelectionChange, + getIconColor, + filterText, + onKeyDown: onKeyDownProp, } = props; const [nodesById, setNodesById] = useState>( () => new Map( - Object.entries(initialNodes).map(([id, node]) => [id, { ...node, childrenStatus: node.childrenStatus ?? "unloaded" }]) + Object.entries(initialNodes).map(([id, node]) => [ + id, + { ...node, childrenStatus: node.childrenStatus ?? "unloaded" }, + ]) ) ); - const [expandedIds, setExpandedIds] = useState>(new Set()); + const [expandedIds, setExpandedIds] = useState>(() => new Set(defaultExpandedIds ?? [])); const [selectedId, setSelectedId] = useState(rootIds[0]); const scrollRef = useRef(null); @@ -242,13 +280,14 @@ export const TreeView = forwardRef((props, ref) => { ]) ) ); - }, [initialNodes]); + setExpandedIds(new Set(defaultExpandedIds ?? [])); + }, [initialNodes, defaultExpandedIds]); - const visibleRows = useMemo(() => buildVisibleRows(nodesById, rootIds, expandedIds), [nodesById, rootIds, expandedIds]); - const idToIndex = useMemo( - () => new Map(visibleRows.map((row, index) => [row.id, index])), - [visibleRows] + const visibleRows = useMemo( + () => buildVisibleRows(nodesById, rootIds, expandedIds, filterText), + [nodesById, rootIds, expandedIds, filterText] ); + const idToIndex = useMemo(() => new Map(visibleRows.map((row, index) => [row.id, index])), [visibleRows]); const virtualizer = useVirtualizer({ count: visibleRows.length, getScrollElement: () => scrollRef.current, @@ -281,58 +320,76 @@ export const TreeView = forwardRef((props, ref) => { [idToIndex, virtualizer] ); - const loadChildren = async (id: string) => { - const currentNode = nodesById.get(id); - if (currentNode == null || !currentNode.isDirectory || currentNode.notfound || currentNode.staterror || fetchDir == null) { - return; - } - const status = currentNode.childrenStatus ?? "unloaded"; - if (status !== "unloaded") { - return; - } - setNodesById((prev) => { - const next = new Map(prev); - next.set(id, { ...currentNode, childrenStatus: "loading" }); - return next; - }); - try { - const result = await fetchDir(id, maxDirEntries); + const loadChildren = useCallback( + async (id: string) => { + const currentNode = nodesById.get(id); + if ( + currentNode == null || + !currentNode.isDirectory || + currentNode.notfound || + currentNode.staterror || + fetchDir == null + ) { + return; + } + const status = currentNode.childrenStatus ?? "unloaded"; + if (status !== "unloaded") { + return; + } setNodesById((prev) => { const next = new Map(prev); - result.nodes.forEach((node) => { - const merged: TreeNodeData = { - ...node, - parentId: node.parentId ?? id, - childrenStatus: node.childrenStatus ?? (node.isDirectory ? "unloaded" : "loaded"), - }; - next.set(merged.id, merged); - }); - const childrenIds = sortIdsByNode( - next, - result.nodes.map((entry) => entry.id) - ); - const source = next.get(id) ?? currentNode; - next.set(id, { - ...source, - childrenIds, - childrenStatus: result.capped ? "capped" : "loaded", - capInfo: result.capped ? { max: maxDirEntries, totalKnown: result.totalKnown } : undefined, - }); + next.set(id, { ...currentNode, childrenStatus: "loading" }); return next; }); - } catch (error) { - setNodesById((prev) => { - const next = new Map(prev); - const source = next.get(id) ?? currentNode; - next.set(id, { - ...source, - childrenStatus: "error", - staterror: error instanceof Error ? error.message : "Unknown error", + try { + const result = await fetchDir(id, maxDirEntries); + setNodesById((prev) => { + const next = new Map(prev); + result.nodes.forEach((node) => { + const merged: TreeNodeData = { + ...node, + parentId: node.parentId ?? id, + childrenStatus: node.childrenStatus ?? (node.isDirectory ? "unloaded" : "loaded"), + }; + next.set(merged.id, merged); + }); + const childrenIds = sortIdsByNode( + next, + result.nodes.map((entry) => entry.id) + ); + const source = next.get(id) ?? currentNode; + next.set(id, { + ...source, + childrenIds, + childrenStatus: result.capped ? "capped" : "loaded", + capInfo: result.capped ? { max: maxDirEntries, totalKnown: result.totalKnown } : undefined, + }); + return next; }); - return next; - }); - } - }; + } catch (error) { + setNodesById((prev) => { + const next = new Map(prev); + const source = next.get(id) ?? currentNode; + next.set(id, { + ...source, + childrenStatus: "error", + staterror: error instanceof Error ? error.message : "Unknown error", + }); + return next; + }); + } + }, + [nodesById, fetchDir, maxDirEntries] + ); + + useEffect(() => { + expandedIds.forEach((id) => { + const node = nodesById.get(id); + if (node && node.isDirectory && (node.childrenStatus ?? "unloaded") === "unloaded") { + loadChildren(id); + } + }); + }, [expandedIds, nodesById, loadChildren]); const toggleExpand = (id: string) => { const node = nodesById.get(id); @@ -368,6 +425,10 @@ export const TreeView = forwardRef((props, ref) => { }; const onKeyDown = (event: KeyboardEvent) => { + onKeyDownProp?.(event); + if (event.isDefaultPrevented()) { + return; + } const selectedIndex = selectedId != null ? idToIndex.get(selectedId) : undefined; if (event.key === "ArrowDown") { event.preventDefault(); @@ -455,7 +516,15 @@ export const TreeView = forwardRef((props, ref) => { height: rowHeight, transform: `translateY(${virtualRow.start}px)`, }} - onClick={() => row.kind === "node" && commitSelection(row.id)} + onClick={() => { + if (row.kind !== "node") { + return; + } + commitSelection(row.id); + if (!row.isDirectory && row.node != null) { + onOpenFile?.(row.id, row.node); + } + }} onDoubleClick={() => { if (row.kind !== "node") { return; @@ -471,7 +540,10 @@ export const TreeView = forwardRef((props, ref) => { >
{row.kind === "node" && row.isDirectory && row.hasChildren ? ( + )} +
+ )}
diff --git a/frontend/app/view/preview/preview-edit.tsx b/frontend/app/view/preview/preview-edit.tsx index 2961771fa3..16bac76cc1 100644 --- a/frontend/app/view/preview/preview-edit.tsx +++ b/frontend/app/view/preview/preview-edit.tsx @@ -41,6 +41,8 @@ function CodeEditPreview({ model }: SpecializedViewProps) { const setNewFileContent = useSetAtom(model.newFileContent); const fileInfo = useAtomValue(model.statFile); const fileName = fileInfo?.path || fileInfo?.name; + const blockData = useAtomValue(model.blockAtom); + const lineVal = blockData?.meta?.["editor:line"]; const baseName = fileName ? fileName.split("/").pop() : null; const language = baseName && shellFileMap[baseName] ? shellFileMap[baseName] : undefined; @@ -73,6 +75,18 @@ function CodeEditPreview({ model }: SpecializedViewProps) { }; }, []); + useEffect(() => { + const editor = model.monacoRef.current; + if (editor && lineVal) { + const lineNum = typeof lineVal === "number" ? lineVal : parseInt(lineVal, 10); + if (!isNaN(lineNum) && lineNum > 0) { + editor.revealLineInCenter(lineNum); + editor.setPosition({ lineNumber: lineNum, column: 1 }); + editor.focus(); + } + } + }, [lineVal, fileContent]); + function onMount(editor: MonacoTypes.editor.IStandaloneCodeEditor, monacoApi: typeof monaco): () => void { model.monacoRef.current = editor; @@ -85,6 +99,15 @@ function CodeEditPreview({ model }: SpecializedViewProps) { } }); + const currentLineVal = globalStore.get(model.blockAtom)?.meta?.["editor:line"]; + if (currentLineVal) { + const lineNum = typeof currentLineVal === "number" ? currentLineVal : parseInt(currentLineVal, 10); + if (!isNaN(lineNum) && lineNum > 0) { + editor.revealLineInCenter(lineNum); + editor.setPosition({ lineNumber: lineNum, column: 1 }); + } + } + const isFocused = globalStore.get(model.nodeModel.isFocused); if (isFocused) { editor.focus(); diff --git a/frontend/app/view/preview/preview-model.tsx b/frontend/app/view/preview/preview-model.tsx index 8315e48b2a..7d1bc75be6 100644 --- a/frontend/app/view/preview/preview-model.tsx +++ b/frontend/app/view/preview/preview-model.tsx @@ -6,7 +6,9 @@ import { ContextMenuModel } from "@/app/store/contextmenu"; import { globalStore } from "@/app/store/jotaiStore"; import type { TabModel } from "@/app/store/tab-model"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { getOverrideConfigAtom, refocusNode } from "@/store/global"; +import { createBlockSplitVertically, getOverrideConfigAtom, refocusNode, getBlockComponentModel } from "@/store/global"; +import { getLayoutModelForStaticTab } from "@/layout/index"; +import { walkNodes } from "@/layout/lib/layoutNode"; import * as WOS from "@/store/wos"; import { goHistory, goHistoryBack, goHistoryForward } from "@/util/historyutil"; import { checkKeyPressed } from "@/util/keyutil"; @@ -335,6 +337,23 @@ export class PreviewModel implements ViewModel { if (mimeType == "directory") { const showHiddenFiles = get(this.showHiddenFiles); return [ + { + elemtype: "iconbutton", + icon: "magnifying-glass", + title: "Search Files (/) or (Cmd+F)", + click: () => { + const curPath = globalStore.get(this.metaFilePath); + const conn = globalStore.get(this.connectionImmediate); + this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: `block:${this.blockId}`, + meta: { + view: "search", + file: curPath, + connection: conn, + }, + }); + }, + }, { elemtype: "iconbutton", icon: showHiddenFiles ? "eye" : "eye-slash", @@ -429,6 +448,7 @@ export class PreviewModel implements ViewModel { this.fileMimeTypeLoadable = loadable(this.fileMimeType); this.newFileContent = atom(null) as PrimitiveAtom; this.goParentDirectory = this.goParentDirectory.bind(this); + this.formatRemoteUri = this.formatRemoteUri.bind(this); const fullFileAtom = atom>(async (get) => { get(this.refreshVersion); // Subscribe to refreshVersion to trigger re-fetch @@ -687,8 +707,56 @@ export class PreviewModel implements ViewModel { return true; } try { - this.goHistory(filePath); - refocusNode(this.blockId); + const blockData = globalStore.get(this.blockAtom); + const isTreeMode = blockData?.meta?.["preview:treemode"] === true; + if (isTreeMode) { + const layoutModel = getLayoutModelForStaticTab(); + let targetBlockId: string | null = null; + if (layoutModel) { + walkNodes(layoutModel.treeState.rootNode, (node) => { + const bId = node.data?.blockId; + if (bId && bId !== this.blockId) { + const otherBlockAtom = WOS.getWaveObjectAtom(`block:${bId}`); + const otherBlockData = globalStore.get(otherBlockAtom); + if (otherBlockData?.meta?.view === "preview" && !otherBlockData?.meta?.["preview:treemode"]) { + targetBlockId = bId; + } + } + }); + } + if (targetBlockId) { + const targetBCM = getBlockComponentModel(targetBlockId); + if (targetBCM && targetBCM.viewModel) { + const targetModel = targetBCM.viewModel as PreviewModel; + if (targetModel.fileContentSaved) { + globalStore.set(targetModel.fileContentSaved, null); + } + if (targetModel.newFileContent) { + globalStore.set(targetModel.newFileContent, null); + } + } + await this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: `block:${targetBlockId}`, + meta: { + file: filePath, + connection: blockData?.meta?.connection ?? "", + }, + }); + refocusNode(targetBlockId); + } else { + const blockDef = { + meta: { + view: "preview", + file: filePath, + connection: blockData?.meta?.connection ?? "", + }, + }; + await createBlockSplitVertically(blockDef, this.blockId, "after"); + } + } else { + this.goHistory(filePath); + refocusNode(this.blockId); + } } catch (e) { globalStore.set(this.openFileError, e.message); console.error("Error opening file", filePath, e); @@ -819,6 +887,24 @@ export class PreviewModel implements ViewModel { } keyDownHandler(e: WaveKeyboardEvent): boolean { + const mimeType = jotaiLoadableValue(globalStore.get(this.fileMimeTypeLoadable), ""); + if (mimeType == "directory" && (e.key === "/" || checkKeyPressed(e, "Cmd:f"))) { + const activeElem = document.activeElement; + const inInput = activeElem != null && (activeElem.tagName == "INPUT" || activeElem.tagName == "TEXTAREA" || activeElem.contentEditable == "true"); + if (!inInput) { + const curPath = globalStore.get(this.metaFilePath); + const conn = globalStore.get(this.connectionImmediate); + this.env.rpc.SetMetaCommand(TabRpcClient, { + oref: `block:${this.blockId}`, + meta: { + view: "search", + file: curPath, + connection: conn, + }, + }); + return true; + } + } if (checkKeyPressed(e, "Cmd:ArrowLeft")) { fireAndForget(this.goHistoryBack.bind(this)); return true; diff --git a/frontend/app/view/search/search.tsx b/frontend/app/view/search/search.tsx new file mode 100644 index 0000000000..ad373ef49c --- /dev/null +++ b/frontend/app/view/search/search.tsx @@ -0,0 +1,536 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BlockNodeModel } from "@/app/block/blocktypes"; +import { globalStore } from "@/app/store/jotaiStore"; +import type { TabModel } from "@/app/store/tab-model"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { getBlockComponentModel, refocusNode, createBlockSplitVertically } from "@/store/global"; +import { getLayoutModelForStaticTab } from "@/layout/index"; +import { walkNodes } from "@/layout/lib/layoutNode"; +import * as WOS from "@/store/wos"; +import { WaveEnv } from "@/app/waveenv/waveenv"; +import { formatRemoteUri } from "@/util/waveutil"; +import { makeIconClass, isBlank } from "@/util/util"; +import * as jotai from "jotai"; +import * as React from "react"; +import { PreviewModel } from "@/app/view/preview/preview-model"; +import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; + +// ---- Types ---- + +interface CommandFileSearchData { + path: string; + query: string; + ignorecase: boolean; + regex: boolean; +} + +interface FileSearchMatch { + linenum: number; + line: string; +} + +interface FileSearchResult { + path: string; + matches: FileSearchMatch[]; +} + +interface TreeFolderNode { + id: string; + name: string; + path: string; + isDirectory: true; + children: (TreeFolderNode | TreeFileNode)[]; + matchCount: number; +} + +interface TreeFileNode { + id: string; + name: string; + path: string; + isDirectory: false; + matches: FileSearchMatch[]; + matchCount: number; + parentPathStr?: string; +} + +type SearchTreeNode = TreeFolderNode | TreeFileNode; + +// ---- View Model ---- + +export class SearchViewModel implements ViewModel { + viewType: string; + blockId: string; + nodeModel: BlockNodeModel; + tabModel: TabModel; + env: WaveEnv; + + viewIcon = jotai.atom("search"); + viewName = jotai.atom("Search"); + noPadding = jotai.atom(true); + + blockAtom: jotai.Atom; + connection: jotai.Atom; + searchPath: jotai.Atom; + + constructor({ blockId, nodeModel, tabModel, waveEnv }: ViewModelInitType) { + this.viewType = "search"; + this.blockId = blockId; + this.nodeModel = nodeModel; + this.tabModel = tabModel; + this.env = waveEnv; + + this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + this.connection = jotai.atom((get) => { + return get(this.blockAtom)?.meta?.connection ?? ""; + }); + this.searchPath = jotai.atom((get) => { + return get(this.blockAtom)?.meta?.file ?? "~"; + }); + } + + get viewComponent(): ViewComponent { + return SearchView; + } +} + +// ---- Helpers ---- + +function getFileIcon(name: string, isDirectory: boolean, isExpanded?: boolean): string { + if (isDirectory) { + return isExpanded ? "folder-open" : "folder"; + } + const extension = name.split(".").pop()?.toLowerCase() ?? ""; + if (["js", "jsx", "ts", "tsx", "go", "py", "java", "c", "cpp", "h", "hpp", "json", "yaml", "yml"].includes(extension)) { + return "file-code"; + } + if (["md", "txt", "log"].includes(extension)) { + return "file-lines"; + } + return "file"; +} + +function getIconColor(name: string, isDirectory: boolean): string { + if (isDirectory) { + return "var(--color-folder, var(--term-bright-blue))"; + } + const label = name.toLowerCase(); + if (label === "dockerfile" || label.startsWith("docker-compose")) { + return "#0db7ed"; + } + if (label.startsWith(".env")) { + return "#c5c5c5"; + } + if (label.startsWith(".git")) { + return "#f1502f"; + } + const extension = label.split(".").pop(); + switch (extension) { + case "py": + return "#3572a5"; + case "js": + case "jsx": + return "#f1e05a"; + case "ts": + case "tsx": + return "#3178c6"; + case "json": + return "#cbcb41"; + case "yaml": + case "yml": + return "#cb6341"; + case "md": + case "mdx": + return "#0083fe"; + case "html": + return "#e34c26"; + case "css": + case "scss": + return "#563d7c"; + case "go": + return "#00add8"; + default: + return "var(--grey-text-color, #888888)"; + } +} + +function buildSearchTree(results: FileSearchResult[], searchDir: string): SearchTreeNode[] { + const rootName = searchDir.split("/").filter(Boolean).pop() || "Root"; + + const rootFolder: TreeFolderNode = { + id: "search-root", + name: rootName, + path: searchDir, + isDirectory: true, + children: [], + matchCount: 0 + }; + + const fileNodes: TreeFileNode[] = []; + + for (const res of results) { + let relPath = res.path; + if (res.path.startsWith(searchDir)) { + relPath = res.path.slice(searchDir.length); + if (relPath.startsWith("/")) { + relPath = relPath.slice(1); + } + } else { + const parts = res.path.split("/"); + if (parts.length > 2) { + relPath = parts.slice(-2).join("/"); + } + } + + const parts = relPath.split("/").filter(Boolean); + if (parts.length === 0) continue; + + const fileDirParts = parts.slice(0, -1); + const fileName = parts[parts.length - 1]; + const parentPathStr = fileDirParts.join("/"); + + fileNodes.push({ + id: res.path, + name: fileName, + path: res.path, + isDirectory: false, + matches: res.matches, + matchCount: res.matches.length, + parentPathStr: parentPathStr + }); + } + + fileNodes.sort((a, b) => a.name.localeCompare(b.name)); + + rootFolder.children = fileNodes; + rootFolder.matchCount = fileNodes.reduce((sum, f) => sum + f.matchCount, 0); + + return [rootFolder]; +} + +// ---- React Components ---- + +interface SearchViewProps { + blockId: string; + model: SearchViewModel; +} + +function SearchView({ blockId, model }: SearchViewProps) { + const connName = jotai.useAtomValue(model.connection); + const searchDir = jotai.useAtomValue(model.searchPath); + + const [query, setQuery] = React.useState(""); + const [matchCase, setMatchCase] = React.useState(false); + const [useRegex, setUseRegex] = React.useState(false); + + const [results, setResults] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [errorMsg, setErrorMsg] = React.useState(null); + + const [expanded, setExpanded] = React.useState>({}); + + const handleSearch = React.useCallback(async (q: string, mc: boolean, rx: boolean) => { + if (!q) { + setResults([]); + setErrorMsg(null); + return; + } + setLoading(true); + setErrorMsg(null); + try { + const formattedPath = formatRemoteUri(searchDir, connName); + const data: CommandFileSearchData = { + path: formattedPath, + query: q, + ignorecase: !mc, + regex: rx, + }; + const response = await model.env.rpc.FileSearchCommand(TabRpcClient, data); + setResults(response ?? []); + setExpanded({}); + } catch (e: any) { + console.error("File search failed", e); + setErrorMsg(e?.message ?? String(e)); + } finally { + setLoading(false); + } + }, [connName, searchDir]); + + React.useEffect(() => { + if (!query) { + setResults([]); + setErrorMsg(null); + return; + } + const timer = setTimeout(() => { + handleSearch(query, matchCase, useRegex); + }, 300); + return () => clearTimeout(timer); + }, [query, matchCase, useRegex, handleSearch]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSearch(query, matchCase, useRegex); + } else if (e.key === "Escape") { + model.env.rpc.SetMetaCommand(TabRpcClient, { + oref: `block:${model.blockId}`, + meta: { + view: "preview", + file: searchDir, + connection: connName, + }, + }); + } + }; + + const toggleExpand = (id: string) => { + setExpanded(prev => ({ + ...prev, + [id]: prev[id] === false ? true : false + })); + }; + + const handleLineClick = async (filePath: string, lineNum: number) => { + const layoutModel = getLayoutModelForStaticTab(); + let targetBlockId: string | null = null; + if (layoutModel) { + walkNodes(layoutModel.treeState.rootNode, (node) => { + const bId = node.data?.blockId; + if (bId && bId !== blockId) { + const otherBlockAtom = WOS.getWaveObjectAtom(`block:${bId}`); + const otherBlockData = globalStore.get(otherBlockAtom); + if (otherBlockData?.meta?.view === "preview" && !otherBlockData?.meta?.["preview:treemode"]) { + targetBlockId = bId; + } + } + }); + } + + if (targetBlockId) { + const targetBCM = getBlockComponentModel(targetBlockId); + if (targetBCM && targetBCM.viewModel) { + const targetModel = targetBCM.viewModel as PreviewModel; + if (targetModel.fileContentSaved) { + globalStore.set(targetModel.fileContentSaved, null); + } + if (targetModel.newFileContent) { + globalStore.set(targetModel.newFileContent, null); + } + } + await model.env.rpc.SetMetaCommand(TabRpcClient, { + oref: `block:${targetBlockId}`, + meta: { + file: filePath, + connection: connName, + "editor:line": lineNum, + }, + }); + refocusNode(targetBlockId); + } else { + const blockDef = { + meta: { + view: "preview", + file: filePath, + connection: connName, + "editor:line": lineNum, + }, + }; + await createBlockSplitVertically(blockDef, blockId, "after"); + } + }; + + const renderHighlight = (line: string) => { + if (!query) return {line}; + try { + let regex: RegExp; + if (useRegex) { + regex = new RegExp(query, matchCase ? "g" : "gi"); + } else { + const escaped = query.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"); + regex = new RegExp(escaped, matchCase ? "g" : "gi"); + } + const parts = line.split(regex); + const matches = [...line.matchAll(regex)]; + if (parts.length === 1) return {line}; + + return ( + + {parts.map((part, idx) => { + const matchText = matches[idx]?.[0]; + return ( + + {part} + {matchText && ( + + {matchText} + + )} + + ); + })} + + ); + } catch (e) { + return {line}; + } + }; + + const searchTree = React.useMemo(() => buildSearchTree(results, searchDir), [results, searchDir]); + + const totalResultsCount = React.useMemo(() => { + return results.reduce((acc, res) => acc + res.matches.length, 0); + }, [results]); + + const renderNode = (node: SearchTreeNode, depth: number) => { + const isExpanded = expanded[node.id] !== false; + const iconName = getFileIcon(node.name, node.isDirectory, isExpanded); + const iconColor = getIconColor(node.name, node.isDirectory); + + let displayLabel: React.ReactNode = node.name; + if (!node.isDirectory) { + const fileNode = node as TreeFileNode; + if (fileNode.parentPathStr) { + displayLabel = ( + + {node.name} + {fileNode.parentPathStr} + + ); + } + } + + return ( +
+
toggleExpand(node.id)} + className="flex items-center gap-1.5 py-0.75 px-1 hover:bg-hoverbg rounded cursor-pointer select-none text-secondary hover:text-white transition-colors" + style={{ paddingLeft: `${depth * 12 + 4}px` }} + > + + + {displayLabel} + + {node.matchCount} + +
+ {node.isDirectory && isExpanded && ( +
+ {node.children.map(child => renderNode(child, depth + 1))} +
+ )} + {!node.isDirectory && isExpanded && ( +
+ {(node as TreeFileNode).matches.map((match, idx) => ( +
handleLineClick(node.path, match.linenum)} + className="flex items-start gap-2 pr-2 py-1 hover:bg-hoverbg rounded cursor-pointer text-xs text-muted hover:text-white transition-colors" + style={{ paddingLeft: `${(depth + 1) * 12 + 18}px` }} + > + + {renderHighlight(match.line)} + +
+ ))} +
+ )} +
+ ); + }; + + return ( +
+
+ + Search +
+ +
+
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search text in files..." + className="bg-transparent border-0 outline-none flex-grow text-sm min-w-0 pr-16 text-white" + /> +
+ + +
+
+
+ +
+ {loading ? ( +
+ + Searching... +
+ ) : errorMsg ? ( + {errorMsg} + ) : ( + + {totalResultsCount} results in {results.length} files under {searchDir} + + )} +
+ +
+ +
+ {searchTree.map(node => renderNode(node, 0))} +
+
+
+
+ ); +} diff --git a/frontend/app/workspace/widgets.tsx b/frontend/app/workspace/widgets.tsx index deb0cac037..3dfc02f483 100644 --- a/frontend/app/workspace/widgets.tsx +++ b/frontend/app/workspace/widgets.tsx @@ -61,16 +61,18 @@ async function handleWidgetSelect(widget: WidgetConfigType, env: WidgetsEnv) { const meta = blockDef?.meta ?? {}; // For preview widgets targeting a directory (no specific file), resolve // the path from the focused terminal's cwd. - if (meta.view === "preview" && (isBlank(meta.file) || meta.file === "~")) { + if ((meta.view === "preview" || meta.view === "search") && (isBlank(meta.file) || meta.file === "~")) { try { - const focusedData = await env.rpc.GetFocusedBlockDataCommand(TabRpcClient); + const focusedData = await env.rpc.GetFocusedBlockDataCommand(TabRpcClient, { route: TabRpcClient.routeId }); if (focusedData?.viewtype === "term" && focusedData.blockmeta?.["cmd:cwd"]) { const cwd = focusedData.blockmeta["cmd:cwd"]; const resolvedMeta: Record = { ...meta, file: cwd, - "preview:treemode": true, }; + if (meta.view === "preview") { + resolvedMeta["preview:treemode"] = true; + } if (focusedData.connname) { resolvedMeta.connection = focusedData.connname; } diff --git a/frontend/extend-compat.ts b/frontend/extend-compat.ts new file mode 100644 index 0000000000..3157b12130 --- /dev/null +++ b/frontend/extend-compat.ts @@ -0,0 +1,2 @@ +import extend from "extend/index.js"; +export default extend; diff --git a/frontend/preview/previews/widgets.preview.tsx b/frontend/preview/previews/widgets.preview.tsx index 8a454bb642..1a4c0dfeec 100644 --- a/frontend/preview/previews/widgets.preview.tsx +++ b/frontend/preview/previews/widgets.preview.tsx @@ -79,17 +79,20 @@ const mockWidgets: { [key: string]: WidgetConfigType } = { const fullConfigAtom = atom({ settings: {}, widgets: mockWidgets } as unknown as FullConfigType); -function makeWidgetsEnv( - baseEnv: WaveEnv, - isDev: boolean, - apps?: AppInfo[], - atomOverrides?: Partial -) { +function makeWidgetsEnv(baseEnv: WaveEnv, isDev: boolean, apps?: AppInfo[], atomOverrides?: Partial) { return applyMockEnvOverrides(baseEnv, { isDev, rpc: { ListAllAppsCommand: () => Promise.resolve(apps ?? []), - GetFocusedBlockDataCommand: () => Promise.resolve(null), + GetFocusedBlockDataCommand: () => + Promise.resolve({ + viewtype: "term", + controller: "shell", + connname: "local", + blockmeta: { + "cmd:cwd": "/tmp/waveterm", + }, + } as FocusedBlockData), }, atoms: { fullConfigAtom, diff --git a/frontend/style-to-js-compat.ts b/frontend/style-to-js-compat.ts new file mode 100644 index 0000000000..5ffa5fd3a2 --- /dev/null +++ b/frontend/style-to-js-compat.ts @@ -0,0 +1,2 @@ +import StyleToJS from "style-to-js/cjs/index.js"; +export default StyleToJS; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index c5b870d7ed..20cef452cb 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -391,6 +391,14 @@ declare global { restoretofilename: string; }; + // wshrpc.CommandFileSearchData + type CommandFileSearchData = { + path: string; + query: string; + ignorecase: boolean; + regex: boolean; + }; + // wshrpc.CommandFileStreamData type CommandFileStreamData = { info: FileInfo; @@ -994,6 +1002,18 @@ declare global { append?: boolean; }; + // wshrpc.FileSearchMatch + type FileSearchMatch = { + linenum: number; + line: string; + }; + + // wshrpc.FileSearchResult + type FileSearchResult = { + path: string; + matches: FileSearchMatch[]; + }; + // wshrpc.FocusedBlockData type FocusedBlockData = { blockid: string; @@ -1589,6 +1609,7 @@ declare global { "debug:panictype"?: string; "block:view"?: string; "block:controller"?: string; + "block:subblock"?: boolean; "ai:backendtype"?: string; "ai:local"?: boolean; "wsh:cmd"?: string; diff --git a/frontend/util/waveutil.ts b/frontend/util/waveutil.ts index 4d0f5952fc..22deb2dc83 100644 --- a/frontend/util/waveutil.ts +++ b/frontend/util/waveutil.ts @@ -90,6 +90,6 @@ export function computeBgStyleFromMeta(meta: Omit=14.14" } }, - "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -5520,468 +5508,6 @@ "import-meta-resolve": "^4.2.0" } }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz", - "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.0" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", - "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.0" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz", - "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", - "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz", - "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz", - "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz", - "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz", - "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", - "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", - "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", - "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", - "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.0" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz", - "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.0" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz", - "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.0" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz", - "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.0" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", - "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.0" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", - "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", - "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.0" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz", - "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/runtime": "^1.4.4" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", - "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz", - "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", - "integrity": "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -28900,61 +28426,6 @@ "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", "license": "MIT" }, - "node_modules/sharp": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", - "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.4", - "semver": "^7.7.2" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.3", - "@img/sharp-darwin-x64": "0.34.3", - "@img/sharp-libvips-darwin-arm64": "1.2.0", - "@img/sharp-libvips-darwin-x64": "1.2.0", - "@img/sharp-libvips-linux-arm": "1.2.0", - "@img/sharp-libvips-linux-arm64": "1.2.0", - "@img/sharp-libvips-linux-ppc64": "1.2.0", - "@img/sharp-libvips-linux-s390x": "1.2.0", - "@img/sharp-libvips-linux-x64": "1.2.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", - "@img/sharp-libvips-linuxmusl-x64": "1.2.0", - "@img/sharp-linux-arm": "0.34.3", - "@img/sharp-linux-arm64": "0.34.3", - "@img/sharp-linux-ppc64": "0.34.3", - "@img/sharp-linux-s390x": "0.34.3", - "@img/sharp-linux-x64": "0.34.3", - "@img/sharp-linuxmusl-arm64": "0.34.3", - "@img/sharp-linuxmusl-x64": "0.34.3", - "@img/sharp-wasm32": "0.34.3", - "@img/sharp-win32-arm64": "0.34.3", - "@img/sharp-win32-ia32": "0.34.3", - "@img/sharp-win32-x64": "0.34.3" - } - }, - "node_modules/sharp/node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -29831,46 +29302,6 @@ "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", "license": "MIT" }, - "node_modules/svgo": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz", - "integrity": "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "commander": "^11.1.0", - "css-select": "^5.1.0", - "css-tree": "^3.0.1", - "css-what": "^6.1.0", - "csso": "^5.0.5", - "picocolors": "^1.1.1", - "sax": "^1.4.1" - }, - "bin": { - "svgo": "bin/svgo.js" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/svgo" - } - }, - "node_modules/svgo/node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=16" - } - }, "node_modules/swr": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", diff --git a/pkg/remote/fileshare/wshfs/wshfs.go b/pkg/remote/fileshare/wshfs/wshfs.go index 352f8c1b7b..ba51eceec0 100644 --- a/pkg/remote/fileshare/wshfs/wshfs.go +++ b/pkg/remote/fileshare/wshfs/wshfs.go @@ -301,3 +301,18 @@ func copyInternal(srcConn, destConn *connparse.Connection, opts *wshrpc.FileCopy } return wshclient.RemoteFileCopyCommand(RpcClient, wshrpc.CommandFileCopyData{SrcUri: srcConn.GetFullURI(), DestUri: destConn.GetFullURI(), Opts: opts}, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(destConn.Host), Timeout: timeout}) } + +func FileSearch(ctx context.Context, data wshrpc.CommandFileSearchData) ([]*wshrpc.FileSearchResult, error) { + log.Printf("FileSearch: %v", data.Path) + conn, err := parseConnection(ctx, data.Path) + if err != nil { + return nil, err + } + return wshclient.RemoteFileSearchCommand(RpcClient, wshrpc.CommandFileSearchData{ + Path: conn.Path, + Query: data.Query, + IgnoreCase: data.IgnoreCase, + Regex: data.Regex, + }, &wshrpc.RpcOpts{Route: wshutil.MakeConnectionRouteId(conn.Host)}) +} + diff --git a/pkg/wconfig/defaultconfig/widgets.json b/pkg/wconfig/defaultconfig/widgets.json index 7b36d381a5..47476be3c5 100644 --- a/pkg/wconfig/defaultconfig/widgets.json +++ b/pkg/wconfig/defaultconfig/widgets.json @@ -20,6 +20,16 @@ } } }, + "defwidget@search": { + "display:order": -3.5, + "icon": "search", + "label": "search", + "blockdef": { + "meta": { + "view": "search" + } + } + }, "defwidget@web": { "display:order": -3, "icon": "globe", diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index d5333aec2b..4073e39ecf 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -370,6 +370,12 @@ func FileRestoreBackupCommand(w *wshutil.WshRpc, data wshrpc.CommandFileRestoreB return err } +// command "filesearch", wshserver.FileSearchCommand +func FileSearchCommand(w *wshutil.WshRpc, data wshrpc.CommandFileSearchData, opts *wshrpc.RpcOpts) ([]*wshrpc.FileSearchResult, error) { + resp, err := sendRpcRequestCallHelper[[]*wshrpc.FileSearchResult](w, "filesearch", data, opts) + return resp, err +} + // command "filestream", wshserver.FileStreamCommand func FileStreamCommand(w *wshutil.WshRpc, data wshrpc.CommandFileStreamData, opts *wshrpc.RpcOpts) (*wshrpc.FileInfo, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.FileInfo](w, "filestream", data, opts) @@ -718,6 +724,12 @@ func RemoteFileMultiInfoCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteFile return resp, err } +// command "remotefilesearch", wshserver.RemoteFileSearchCommand +func RemoteFileSearchCommand(w *wshutil.WshRpc, data wshrpc.CommandFileSearchData, opts *wshrpc.RpcOpts) ([]*wshrpc.FileSearchResult, error) { + resp, err := sendRpcRequestCallHelper[[]*wshrpc.FileSearchResult](w, "remotefilesearch", data, opts) + return resp, err +} + // command "remotefilestream", wshserver.RemoteFileStreamCommand func RemoteFileStreamCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteFileStreamData, opts *wshrpc.RpcOpts) (*wshrpc.FileInfo, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.FileInfo](w, "remotefilestream", data, opts) diff --git a/pkg/wshrpc/wshremote/wshremote_file.go b/pkg/wshrpc/wshremote/wshremote_file.go index f336b9d8bb..0ec4c0fe14 100644 --- a/pkg/wshrpc/wshremote/wshremote_file.go +++ b/pkg/wshrpc/wshremote/wshremote_file.go @@ -4,6 +4,7 @@ package wshremote import ( + "bufio" "context" "encoding/base64" "errors" @@ -13,6 +14,7 @@ import ( "log" "os" "path/filepath" + "regexp" "strings" "time" @@ -661,3 +663,164 @@ func (*ServerImpl) RemoteFileDeleteCommand(ctx context.Context, data wshrpc.Comm } return nil } + +func (impl *ServerImpl) RemoteFileSearchCommand(ctx context.Context, data wshrpc.CommandFileSearchData) ([]*wshrpc.FileSearchResult, error) { + searchPath, err := wavebase.ExpandHomeDir(data.Path) + if err != nil { + return nil, fmt.Errorf("invalid search path %q: %w", data.Path, err) + } + searchPath = filepath.Clean(searchPath) + + log.Printf("RemoteFileSearchCommand: path=%q query=%q regex=%v ignorecase=%v resolved_path=%q", data.Path, data.Query, data.Regex, data.IgnoreCase, searchPath) + + if data.Query == "" { + return nil, nil + } + + var re *regexp.Regexp + if data.Regex { + regexQuery := data.Query + if data.IgnoreCase { + regexQuery = "(?i)" + regexQuery + } + var err error + re, err = regexp.Compile(regexQuery) + if err != nil { + return nil, fmt.Errorf("invalid regex: %w", err) + } + } + + queryLower := strings.ToLower(data.Query) + var results []*wshrpc.FileSearchResult + totalMatches := 0 + const maxMatches = 2000 + const maxFileSize = 5 * 1024 * 1024 // 5MB + const maxLineLen = 1000 + + excludedDirs := map[string]bool{ + ".git": true, + "node_modules": true, + ".gemini": true, + ".roo": true, + ".task": true, + ".vscode": true, + ".zed": true, + "dist": true, + "build": true, + "tmp": true, + "__pycache__": true, + ".venv": true, + "venv": true, + "env": true, + ".pytest_cache": true, + "vendor": true, + ".idea": true, + } + + err = filepath.WalkDir(searchPath, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + log.Printf("RemoteFileSearchCommand: walk error at %q: %v", path, walkErr) + if d != nil && d.IsDir() { + return filepath.SkipDir + } + return nil + } + + if d.IsDir() { + if path != searchPath && excludedDirs[d.Name()] { + return filepath.SkipDir + } + return nil + } + + if !d.Type().IsRegular() { + return nil + } + + info, err := d.Info() + if err != nil { + return nil + } + if info.Size() > maxFileSize || info.Size() == 0 { + return nil + } + + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + buf := make([]byte, 1024) + n, err := f.Read(buf) + if err != nil && err != io.EOF { + return nil + } + for i := 0; i < n; i++ { + if buf[i] == 0 { + return nil + } + } + + _, err = f.Seek(0, 0) + if err != nil { + return nil + } + + var matches []wshrpc.FileSearchMatch + scanner := bufio.NewScanner(f) + lineNum := 0 + for scanner.Scan() { + lineNum++ + line := scanner.Text() + matched := false + + if data.Regex { + matched = re.MatchString(line) + } else { + if data.IgnoreCase { + matched = strings.Contains(strings.ToLower(line), queryLower) + } else { + matched = strings.Contains(line, data.Query) + } + } + + if matched { + displayLine := line + if len(displayLine) > maxLineLen { + displayLine = displayLine[:maxLineLen] + "..." + } + matches = append(matches, wshrpc.FileSearchMatch{ + LineNum: lineNum, + Line: displayLine, + }) + totalMatches++ + if totalMatches >= maxMatches { + break + } + } + } + + if len(matches) > 0 { + log.Printf("RemoteFileSearchCommand: found %d matches in %q", len(matches), path) + results = append(results, &wshrpc.FileSearchResult{ + Path: path, + Matches: matches, + }) + } + + if totalMatches >= maxMatches { + return io.EOF + } + + return nil + }) + + if err != nil && err != io.EOF { + log.Printf("RemoteFileSearchCommand: walk completed with error: %v", err) + return nil, err + } + + log.Printf("RemoteFileSearchCommand: completed search, found %d results in %d files", totalMatches, len(results)) + return results, nil +} diff --git a/pkg/wshrpc/wshrpctypes_file.go b/pkg/wshrpc/wshrpctypes_file.go index 3c64bb2289..3d777a79d2 100644 --- a/pkg/wshrpc/wshrpctypes_file.go +++ b/pkg/wshrpc/wshrpctypes_file.go @@ -25,6 +25,7 @@ type WshRpcFileInterface interface { FileJoinCommand(ctx context.Context, paths []string) (*FileInfo, error) FileListStreamCommand(ctx context.Context, data FileListData) <-chan RespOrErrorUnion[CommandRemoteListEntriesRtnData] FileStreamCommand(ctx context.Context, data CommandFileStreamData) (*FileInfo, error) + FileSearchCommand(ctx context.Context, data CommandFileSearchData) ([]*FileSearchResult, error) } type WshRpcRemoteFileInterface interface { @@ -39,6 +40,7 @@ type WshRpcRemoteFileInterface interface { RemoteWriteFileCommand(ctx context.Context, data FileData) error RemoteFileJoinCommand(ctx context.Context, paths []string) (*FileInfo, error) RemoteMkdirCommand(ctx context.Context, path string) error + RemoteFileSearchCommand(ctx context.Context, data CommandFileSearchData) ([]*FileSearchResult, error) } type FileDataAt struct { @@ -155,3 +157,20 @@ type CommandRemoteFileMultiInfoData struct { type CommandRemoteListEntriesRtnData struct { FileInfo []*FileInfo `json:"fileinfo,omitempty"` } + +type CommandFileSearchData struct { + Path string `json:"path"` + Query string `json:"query"` + IgnoreCase bool `json:"ignorecase"` + Regex bool `json:"regex"` +} + +type FileSearchMatch struct { + LineNum int `json:"linenum"` + Line string `json:"line"` +} + +type FileSearchResult struct { + Path string `json:"path"` + Matches []FileSearchMatch `json:"matches"` +} diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 38006fd9a8..f93e579766 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -399,6 +399,11 @@ func (ws *WshServer) FileAppendCommand(ctx context.Context, data wshrpc.FileData return wshfs.Append(ctx, data) } +func (ws *WshServer) FileSearchCommand(ctx context.Context, data wshrpc.CommandFileSearchData) ([]*wshrpc.FileSearchResult, error) { + return wshfs.FileSearch(ctx, data) +} + + func (ws *WshServer) FileJoinCommand(ctx context.Context, paths []string) (*wshrpc.FileInfo, error) { if len(paths) < 2 { if len(paths) == 0 { From baf4b59fbc45b4b325080451e52b1c901b0a9d53 Mon Sep 17 00:00:00 2001 From: tungnguyentu Date: Sat, 27 Jun 2026 19:05:32 +0700 Subject: [PATCH 3/4] fix: resolve production package window fail to open due to broken mermaid import --- electron.vite.config.ts | 15 --------------- frontend/app/element/markdown.tsx | 2 +- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 2098429869..e0fadb7d70 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -12,19 +12,6 @@ import tsconfigPaths from "vite-tsconfig-paths"; // from our electron build const CHROME = "chrome140"; const NODE = "node22"; -const MERMAID_CORE = path.resolve(__dirname, "node_modules/mermaid/dist/mermaid.core.mjs"); - -function streamdownMermaidFix() { - return { - name: "streamdown-mermaid-fix", - enforce: "pre" as const, - transform(code: string, id: string) { - if (!id.includes("node_modules/streamdown")) return null; - if (!code.includes("import('mermaid')")) return null; - return code.replaceAll("import('mermaid')", `import('/@fs${MERMAID_CORE}')`); - }, - }; -} export default defineConfig({ main: { @@ -43,7 +30,6 @@ export default defineConfig({ resolve: { alias: { "@": "frontend", - mermaid: path.resolve(__dirname, "node_modules/mermaid/dist/mermaid.js"), }, }, server: { @@ -148,7 +134,6 @@ export default defineConfig({ }, plugins: [ tsconfigPaths(), - streamdownMermaidFix(), { ...ViteImageOptimizer(), apply: "build" }, svgr({ svgrOptions: { exportType: "default", ref: true, svgo: false, titleProp: true }, diff --git a/frontend/app/element/markdown.tsx b/frontend/app/element/markdown.tsx index 317df38cdb..072d82fdfe 100644 --- a/frontend/app/element/markdown.tsx +++ b/frontend/app/element/markdown.tsx @@ -31,7 +31,7 @@ let mermaidInstance: any = null; const initializeMermaid = async () => { if (!mermaidInitialized) { - const mermaid = await import("mermaid/dist/mermaid.js"); + const mermaid = await import("mermaid"); mermaidInstance = mermaid.default; mermaidInstance.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); mermaidInitialized = true; From 13ff29e79ca2ab921550b4df9a261cc1eba05f3c Mon Sep 17 00:00:00 2001 From: tungnguyentu Date: Sat, 27 Jun 2026 19:42:47 +0700 Subject: [PATCH 4/4] fix: resolve path encoding issue converting spaces to plus signs (+) --- frontend/app/view/term/osc-handlers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/view/term/osc-handlers.ts b/frontend/app/view/term/osc-handlers.ts index 7fe7dcd4ee..d656109b64 100644 --- a/frontend/app/view/term/osc-handlers.ts +++ b/frontend/app/view/term/osc-handlers.ts @@ -238,7 +238,7 @@ export function handleOsc7Command(data: string, blockId: string, loaded: boolean console.log("Invalid OSC 7 command received (non-file protocol)", data); return true; } - pathPart = decodeURIComponent(url.pathname); + pathPart = decodeURIComponent(url.pathname.replace(/\+/g, " ")); // Normalize double slashes at the beginning to single slash if (pathPart.startsWith("//")) {