diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index a51a6642bf..2c35674b29 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -94,7 +94,7 @@ wsh editconfig | preview:showhiddenfiles | bool | set to false to disable showing hidden files in the directory preview (defaults to true) | | preview:defaultsort | string | sets the default sort column for directory preview. `"name"` (default) sorts alphabetically by name ascending; `"modtime"` sorts by last modified time descending (newest first) | | preview:dirsfirst | bool | list folders before files in the file browser, whatever the sort column or direction (defaults to true) | -| preview:doubleclickopen | string | what double-clicking a file in the file browser does: `"external"` (default) opens it in the OS default application (Windows shows the "Open with" picker if nothing is associated), `"preview"` opens it inside Wave. Remote (SSH/WSL) files are downloaded to a temp folder, opened locally, and uploaded back to the host every time you save them; if the remote file changed since you opened it you are asked before it is overwritten | +| preview:doubleclickopen | string | what double-clicking a file in the file browser does: `"auto"` (default) opens text files (by extension or mimetype) as a preview in a new block and everything else in the OS default application, `"external"` always opens it in the OS default application (Windows shows the "Open with" picker if nothing is associated), `"preview"` opens it inside Wave. Remote (SSH/WSL) files are downloaded to a temp folder, opened locally, and uploaded back to the host every time you save them; if the remote file changed since you opened it you are asked before it is overwritten | | preview:externaleditor | string | path to the editor used by the "Open in …" file menu item (e.g. `C:\\Program Files\\Notepad++\\notepad++.exe`). When empty, Notepad++ is detected in its standard Windows install location | | markdown:fontsize | float64 | font size for the normal text when rendering markdown in preview. headers are scaled up from this size, (default 14px) | | markdown:fixedfontsize | float64 | font size for the code blocks when rendering markdown in preview (default is 12px) | diff --git a/frontend/app/view/preview/preview-directory.tsx b/frontend/app/view/preview/preview-directory.tsx index 2a340738dd..0d01a38780 100644 --- a/frontend/app/view/preview/preview-directory.tsx +++ b/frontend/app/view/preview/preview-directory.tsx @@ -9,7 +9,8 @@ import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import { checkKeyPressed, isCharacterKeyEvent } from "@/util/keyutil"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; -import { addOpenMenuItems, openFileExternally } from "@/util/previewutil"; +import { addOpenMenuItems, openFileExternally, openPreviewInNewBlock } from "@/util/previewutil"; +import { isTextCapableFile } from "@/util/textfiles"; import { fireAndForget } from "@/util/util"; import { formatRemoteUri } from "@/util/waveutil"; import { offset, useDismiss, useFloating, useInteractions } from "@floating-ui/react"; @@ -600,7 +601,7 @@ function TableRow({ const env = useWaveEnv(); const dirPath = useAtomValue(model.statFilePath); const connection = useAtomValue(model.connection); - const doubleClickOpen = useAtomValue(env.getSettingsKeyAtom("preview:doubleclickopen")) ?? "external"; + const doubleClickOpen = useAtomValue(env.getSettingsKeyAtom("preview:doubleclickopen")) ?? "auto"; const dragItem: DraggedFile = { relName: row.getValue("name") as string, @@ -630,6 +631,14 @@ function TableRow({ data-rowindex={idx} onDoubleClick={() => { const newFileName = row.getValue("path") as string; + if (!row.original.isdir && doubleClickOpen === "auto") { + if (isTextCapableFile(row.original.name, row.original.mimetype)) { + openPreviewInNewBlock(newFileName, connection); + return; + } + openFileExternally(newFileName, connection, "default"); + return; + } if (!row.original.isdir && doubleClickOpen === "external") { openFileExternally(newFileName, connection, "default"); return; diff --git a/frontend/util/previewutil.ts b/frontend/util/previewutil.ts index 3e251b98ec..3a0cd6231b 100644 --- a/frontend/util/previewutil.ts +++ b/frontend/util/previewutil.ts @@ -15,6 +15,17 @@ export function openFileExternally(path: string, conn: string, mode: OpenExterna ); } +export function openPreviewInNewBlock(path: string, conn: string) { + const blockDef: BlockDef = { + meta: { + view: "preview", + file: path, + connection: conn, + }, + }; + fireAndForget(() => createBlock(blockDef)); +} + function addExternalOpenItems(menu: ContextMenuItem[], conn: string, finfo: FileInfo) { const remoteSuffix = conn ? " (edit locally)" : ""; menu.push({ @@ -73,17 +84,7 @@ export function addOpenMenuItems(menu: ContextMenuItem[], conn: string, finfo: F if (!finfo.isdir) { menu.push({ label: "Open Preview in New Block", - click: () => - fireAndForget(async () => { - const blockDef: BlockDef = { - meta: { - view: "preview", - file: finfo.path, - connection: conn, - }, - }; - await createBlock(blockDef); - }), + click: () => openPreviewInNewBlock(finfo.path, conn), }); } menu.push({ diff --git a/frontend/util/textfiles.test.ts b/frontend/util/textfiles.test.ts new file mode 100644 index 0000000000..fcf3d7e73f --- /dev/null +++ b/frontend/util/textfiles.test.ts @@ -0,0 +1,31 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { isTextCapableFile } from "./textfiles"; + +describe("isTextCapableFile", () => { + it("matches by extension even when the mimetype is wrong", () => { + expect(isTextCapableFile("main.ts", "video/mp2t")).toBe(true); + expect(isTextCapableFile("main.go", "application/octet-stream")).toBe(true); + expect(isTextCapableFile("README.MD", "")).toBe(true); + }); + + it("matches dotfiles and well-known extensionless names", () => { + expect(isTextCapableFile(".gitignore", "application/octet-stream")).toBe(true); + expect(isTextCapableFile(".env", null)).toBe(true); + expect(isTextCapableFile("Makefile", null)).toBe(true); + }); + + it("falls back to the mimetype", () => { + expect(isTextCapableFile("notes.weird", "text/plain; charset=utf-8")).toBe(true); + expect(isTextCapableFile("data.bin", "application/json")).toBe(true); + }); + + it("rejects binaries", () => { + expect(isTextCapableFile("photo.png", "image/png")).toBe(false); + expect(isTextCapableFile("app.exe", "application/octet-stream")).toBe(false); + expect(isTextCapableFile("archive.zip", "application/zip")).toBe(false); + expect(isTextCapableFile("noext", null)).toBe(false); + }); +}); diff --git a/frontend/util/textfiles.ts b/frontend/util/textfiles.ts new file mode 100644 index 0000000000..8703a2e1f6 --- /dev/null +++ b/frontend/util/textfiles.ts @@ -0,0 +1,190 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Mimetype sniffing misses many source files (".ts" is sniffed as video/mp2t, ".go" and dotfiles as +// octet-stream), so the extension list is checked first. +const TextExtensions = new Set([ + // plain text and docs + "txt", + "text", + "md", + "markdown", + "mdx", + "rst", + "adoc", + "org", + "tex", + "log", + "nfo", + "rtf", + // data and config + "json", + "jsonc", + "json5", + "jsonl", + "ndjson", + "yaml", + "yml", + "toml", + "ini", + "cfg", + "conf", + "config", + "properties", + "env", + "xml", + "csv", + "tsv", + "plist", + "lock", + "editorconfig", + "gitignore", + "gitattributes", + "dockerignore", + "npmrc", + "prettierrc", + "eslintrc", + "babelrc", + // web + "html", + "htm", + "xhtml", + "css", + "scss", + "sass", + "less", + "svg", + "vue", + "svelte", + "astro", + // scripting and shell + "sh", + "bash", + "zsh", + "fish", + "ps1", + "psm1", + "bat", + "cmd", + "awk", + "sed", + // languages + "js", + "mjs", + "cjs", + "jsx", + "ts", + "mts", + "cts", + "tsx", + "py", + "pyi", + "rb", + "php", + "pl", + "pm", + "lua", + "r", + "go", + "rs", + "c", + "h", + "cc", + "cpp", + "cxx", + "hpp", + "hh", + "hxx", + "cs", + "java", + "kt", + "kts", + "scala", + "swift", + "m", + "mm", + "dart", + "ex", + "exs", + "erl", + "hs", + "clj", + "cljs", + "fs", + "fsx", + "vb", + "groovy", + "gradle", + "zig", + "nim", + "jl", + "sql", + "graphql", + "gql", + "proto", + "tf", + "tfvars", + "hcl", + "nix", + "cmake", + "make", + "mk", + "dockerfile", + "vim", + "diff", + "patch", + "service", + "timer", + "desktop", +]); + +// extensionless names that are always text +const TextFileNames = new Set([ + "makefile", + "dockerfile", + "readme", + "license", + "licence", + "changelog", + "authors", + "contributing", + "codeowners", + "procfile", + "gemfile", + "rakefile", + "vagrantfile", + "jenkinsfile", + "taskfile", +]); + +function hasTextMimetype(mimeType: string): boolean { + if (mimeType == null) { + return false; + } + return ( + mimeType.startsWith("text/") || + mimeType.includes("json") || + mimeType.includes("yaml") || + mimeType.includes("toml") || + mimeType.includes("xml") || + mimeType.includes("javascript") || + mimeType.includes("typescript") || + mimeType === "application/x-sh" || + mimeType === "application/sql" || + mimeType === "application/x-python" || + mimeType === "application/x-ruby" + ); +} + +export function isTextCapableFile(name: string, mimeType: string): boolean { + const base = (name ?? "").split(/[\\/]/).pop().toLowerCase(); + if (TextFileNames.has(base)) { + return true; + } + const dot = base.lastIndexOf("."); + const ext = dot >= 0 ? base.slice(dot + 1) : ""; + if (ext !== "" && TextExtensions.has(ext)) { + return true; + } + return hasTextMimetype(mimeType); +} diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index cf9651937d..dab8200a91 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -49,5 +49,5 @@ "waveai:defaultmode": "waveai@balanced", "preview:defaultsort": "name", "preview:dirsfirst": true, - "preview:doubleclickopen": "external" + "preview:doubleclickopen": "auto" } diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 98abf18aca..4129bbcb9d 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -142,7 +142,7 @@ type SettingsType struct { PreviewShowHiddenFiles *bool `json:"preview:showhiddenfiles,omitempty"` PreviewDefaultSort string `json:"preview:defaultsort,omitempty" jsonschema:"enum=name,enum=modtime"` PreviewDirsFirst *bool `json:"preview:dirsfirst,omitempty"` - PreviewDoubleClickOpen string `json:"preview:doubleclickopen,omitempty" jsonschema:"enum=external,enum=preview"` + PreviewDoubleClickOpen string `json:"preview:doubleclickopen,omitempty" jsonschema:"enum=auto,enum=external,enum=preview"` PreviewExternalEditor string `json:"preview:externaleditor,omitempty"` TabPreset string `json:"tab:preset,omitempty"` diff --git a/schema/settings.json b/schema/settings.json index 5ac73ebc12..38978326e7 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -260,6 +260,7 @@ "preview:doubleclickopen": { "type": "string", "enum": [ + "auto", "external", "preview" ]