Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/docs/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <VersionBadge version="v0.14.2" /> | 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) |
Expand Down
13 changes: 11 additions & 2 deletions frontend/app/view/preview/preview-directory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -600,7 +601,7 @@ function TableRow({
const env = useWaveEnv<PreviewEnv>();
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,
Expand Down Expand Up @@ -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;
Expand Down
23 changes: 12 additions & 11 deletions frontend/util/previewutil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
31 changes: 31 additions & 0 deletions frontend/util/textfiles.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
190 changes: 190 additions & 0 deletions frontend/util/textfiles.ts
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 1 addition & 1 deletion pkg/wconfig/defaultconfig/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,5 @@
"waveai:defaultmode": "waveai@balanced",
"preview:defaultsort": "name",
"preview:dirsfirst": true,
"preview:doubleclickopen": "external"
"preview:doubleclickopen": "auto"
}
2 changes: 1 addition & 1 deletion pkg/wconfig/settingsconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
1 change: 1 addition & 0 deletions schema/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@
"preview:doubleclickopen": {
"type": "string",
"enum": [
"auto",
"external",
"preview"
]
Expand Down