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"
]