Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ docsite/
.superpowers
docs/superpowers
.claude
.idea/
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,4 @@ Sponsorship helps support the time spent building and maintaining the project.
## License

Wave Terminal is licensed under the Apache-2.0 License. For more information on our dependencies, see [here](./ACKNOWLEDGEMENTS.md).

10 changes: 9 additions & 1 deletion electron-builder.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ const fs = require("fs");
const path = require("path");

const windowsShouldSign = !!process.env.SM_CODE_SIGNING_CERT_SHA1_HASH;
const windowsShouldEditExecutable = windowsShouldSign || process.env.WAVETERM_WINDOWS_EDIT_EXECUTABLE === "1";
const windowsShouldBuildInstallers = windowsShouldSign || process.env.WAVETERM_WINDOWS_INSTALLERS === "1";
const windowsTargets = windowsShouldBuildInstallers ? ["nsis", "msi", "zip"] : ["zip"];
const localWindowsElectronDist = path.resolve(__dirname, "node_modules", "electron", "dist");
const useLocalWindowsElectronDist =
process.platform === "win32" && fs.existsSync(path.join(localWindowsElectronDist, "electron.exe"));

/**
* @type {import('electron-builder').Configuration}
Expand All @@ -18,6 +24,7 @@ const config = {
npmRebuild: false,
nodeGypRebuild: false,
electronCompile: false,
electronDist: useLocalWindowsElectronDist ? localWindowsElectronDist : null,
files: [
{
from: "./dist",
Expand Down Expand Up @@ -96,7 +103,8 @@ const config = {
afterInstall: "build/deb-postinstall.tpl",
},
win: {
target: ["nsis", "msi", "zip"],
target: windowsTargets,
signAndEditExecutable: windowsShouldEditExecutable,
signtoolOptions: windowsShouldSign && {
signingHashAlgorithms: ["sha256"],
publisherName: "Command Line Inc",
Expand Down
3 changes: 3 additions & 0 deletions emain/emain-platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ function getWaveConfigDir(): string {
retVal = override;
} else if (xdgConfigHome) {
retVal = path.join(xdgConfigHome, waveDirName);
} else if (unamePlatform === "win32") {
const legacyConfigDir = path.join(app.getPath("home"), ".config", waveDirName);
retVal = existsSync(legacyConfigDir) ? legacyConfigDir : paths.config;
} else {
retVal = path.join(app.getPath("home"), ".config", waveDirName);
}
Expand Down
134 changes: 80 additions & 54 deletions frontend/app/view/preview/preview-directory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ function DirectoryTable({
);

const setEntryManagerProps = useSetAtom(entryManagerOverlayPropsAtom);
const stat = useAtomValue(model.statFile);
const canCreateEntries = stat?.supportsmkdir === true;

const updateName = useCallback(
(path: string, isDir: boolean) => {
Expand Down Expand Up @@ -228,8 +230,8 @@ function DirectoryTable({
enableSortingRemoval: false,
meta: {
updateName,
newFile,
newDirectory,
newFile: canCreateEntries ? newFile : () => {},
newDirectory: canCreateEntries ? newDirectory : () => {},
},
});
const sortingState = table.getState().sorting;
Expand Down Expand Up @@ -326,6 +328,9 @@ function TableBody({
const dummyLineRef = useRef<HTMLDivElement>(null);
const warningBoxRef = useRef<HTMLDivElement>(null);
const conn = useAtomValue(model.connection);
const stat = useAtomValue(model.statFile);
const canCreateEntries = stat?.supportsmkdir === true;
const canMutateEntries = stat != null && (stat.path !== "/" || stat.supportsmkdir === true);
const setErrorMsg = useSetAtom(model.errorMsgAtom);

useEffect(() => {
Expand Down Expand Up @@ -368,28 +373,37 @@ function TableBody({
return;
}
const fileName = finfo.path.split("/").pop();
const menu: ContextMenuItem[] = [
{
label: "New File",
click: () => {
table.options.meta.newFile();
},
},
{
label: "New Folder",
click: () => {
table.options.meta.newDirectory();
const menu: ContextMenuItem[] = [];
if (canCreateEntries) {
menu.push(
{
label: "New File",
click: () => {
table.options.meta.newFile();
},
},
},
{
{
label: "New Folder",
click: () => {
table.options.meta.newDirectory();
},
}
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if (canMutateEntries) {
menu.push({
label: "Rename",
click: () => {
table.options.meta.updateName(finfo.path, finfo.isdir);
},
},
{
});
}
if (canCreateEntries || canMutateEntries) {
menu.push({
type: "separator",
},
});
}
menu.push(
{
label: "Copy File Name",
click: () => fireAndForget(() => navigator.clipboard.writeText(fileName)),
Expand All @@ -405,28 +419,30 @@ function TableBody({
{
label: "Copy Full File Name (Shell Quoted)",
click: () => fireAndForget(() => navigator.clipboard.writeText(shellQuote([finfo.path]))),
},
];
addOpenMenuItems(menu, conn, finfo);
menu.push(
{
type: "separator",
},
{
label: "Default Settings",
submenu: makeDirectoryDefaultMenuItems(model),
},
{
type: "separator",
},
{
label: "Delete",
click: () => handleFileDelete(model, finfo.path, false, setErrorMsg),
}
);
addOpenMenuItems(menu, conn, finfo);
menu.push({
type: "separator",
});
menu.push({
label: "Default Settings",
submenu: makeDirectoryDefaultMenuItems(model),
});
if (canMutateEntries) {
menu.push(
{
type: "separator",
},
{
label: "Delete",
click: () => handleFileDelete(model, finfo.path, false, setErrorMsg),
}
);
}
ContextMenuModel.getInstance().showContextMenu(menu, e);
},
[setRefreshVersion, conn]
[canCreateEntries, canMutateEntries, conn, setErrorMsg]
);

const allRows = table.getRowModel().flatRows;
Expand Down Expand Up @@ -571,6 +587,7 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) {
const conn = useAtomValue(model.connection);
const blockData = useAtomValue(model.blockAtom);
const finfo = useAtomValue(model.statFile);
const canCreateEntries = finfo?.supportsmkdir === true;
const dirPath = finfo?.path;
const setErrorMsg = useSetAtom(model.errorMsgAtom);

Expand Down Expand Up @@ -796,6 +813,9 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) {
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);

const newFile = useCallback(() => {
if (!canCreateEntries) {
return;
}
setEntryManagerProps({
entryManagerType: EntryManagerType.NewFile,
onSave: (newName: string) => {
Expand All @@ -815,8 +835,11 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) {
setEntryManagerProps(undefined);
},
});
}, [dirPath]);
}, [canCreateEntries, dirPath]);
const newDirectory = useCallback(() => {
if (!canCreateEntries) {
return;
}
setEntryManagerProps({
entryManagerType: EntryManagerType.NewDirectory,
onSave: (newName: string) => {
Expand All @@ -832,34 +855,37 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) {
setEntryManagerProps(undefined);
},
});
}, [dirPath]);
}, [canCreateEntries, dirPath]);

const handleFileContextMenu = useCallback(
(e: any) => {
e.preventDefault();
e.stopPropagation();
const menu: ContextMenuItem[] = [
{
label: "New File",
click: () => {
newFile();
const menu: ContextMenuItem[] = [];
if (canCreateEntries) {
menu.push(
{
label: "New File",
click: () => {
newFile();
},
},
},
{
label: "New Folder",
click: () => {
newDirectory();
{
label: "New Folder",
click: () => {
newDirectory();
},
},
},
{
type: "separator",
},
];
{
type: "separator",
}
);
}
addOpenMenuItems(menu, conn, finfo);

ContextMenuModel.getInstance().showContextMenu(menu, e);
},
[setRefreshVersion, conn, newFile, newDirectory, dirPath]
[canCreateEntries, conn, newFile, newDirectory, dirPath, finfo]
);

return (
Expand Down
31 changes: 31 additions & 0 deletions frontend/app/view/term/termutil.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";

import { getWheelLineDelta } from "./termutil";

describe("getWheelLineDelta", () => {
it("returns 0 for zero and non-finite deltas", () => {
expect(getWheelLineDelta(0, 0, 16, 40)).toBe(0);
expect(getWheelLineDelta(Number.NaN, 0, 16, 40)).toBe(0);
expect(getWheelLineDelta(Number.POSITIVE_INFINITY, 0, 16, 40)).toBe(0);
expect(getWheelLineDelta(Number.NEGATIVE_INFINITY, 0, 16, 40)).toBe(0);
});

it("converts pixel deltas using cell height", () => {
expect(getWheelLineDelta(32, 0, 16, 40)).toBe(2);
expect(getWheelLineDelta(-24, 0, 12, 40)).toBe(-2);
});

it("keeps line deltas unchanged", () => {
expect(getWheelLineDelta(3, 1, 16, 40)).toBe(3);
expect(getWheelLineDelta(-2, 1, 16, 40)).toBe(-2);
});

it("converts page deltas using row count", () => {
expect(getWheelLineDelta(1, 2, 16, 30)).toBe(30);
expect(getWheelLineDelta(-1, 2, 16, 18)).toBe(-18);
});

it("falls back to sane defaults for invalid dimensions", () => {
expect(getWheelLineDelta(16, 0, 0, 0)).toBe(1);
});
});
16 changes: 16 additions & 0 deletions frontend/app/view/term/termutil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,3 +393,19 @@ export function bufferLinesToText(buffer: TermTypes.IBuffer, startIndex: number,
export function quoteForPosixShell(filePath: string): string {
return "'" + filePath.replace(/'/g, "'\\''") + "'";
}

export function getWheelLineDelta(deltaY: number, deltaMode: number, cellHeight: number, rows: number): number {
if (!Number.isFinite(deltaY) || deltaY === 0) {
return 0;
}
const safeCellHeight = Number.isFinite(cellHeight) && cellHeight > 0 ? cellHeight : 16;
const safeRows = Number.isFinite(rows) && rows > 0 ? rows : 1;
switch (deltaMode) {
case 1:
return deltaY;
case 2:
return deltaY * safeRows;
default:
return deltaY / safeCellHeight;
}
}
40 changes: 40 additions & 0 deletions frontend/app/view/term/termwrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
bufferLinesToText,
createTempFileFromBlob,
extractAllClipboardData,
getWheelLineDelta,
normalizeCursorStyle,
quoteForPosixShell,
} from "./termutil";
Expand Down Expand Up @@ -109,6 +110,7 @@ export class TermWrap {
// xterm.js paste() method triggers onData event, which can cause duplicate sends
lastPasteData: string = "";
lastPasteTime: number = 0;
wheelScrollRemainder: number = 0;

// dev only (for debugging)
recentWrites: { idx: number; data: string; ts: number }[] = [];
Expand Down Expand Up @@ -313,6 +315,44 @@ export class TermWrap {
this.connectElem.removeEventListener("drop", dropHandler);
},
});
const wheelHandler = (event: WheelEvent) => {
if (event.defaultPrevented || this.terminal.modes.mouseTrackingMode !== "none") {
return;
}
const target = event.target;
if (target instanceof Element && target.closest(".xterm-viewport") != null) {
return;
}
// This relies on xterm.js private internals (`_core._renderService`) because
// there is no public API for measured cell height yet; fall back to 16px
// (a conservative default line height) so wheel deltas still map to lines,
// and revisit this when xterm exposes public cell dimensions.
const cellHeight = (this.terminal as any)?._core?._renderService?.dimensions?.css?.cell?.height ?? 16;
const lineDelta = getWheelLineDelta(event.deltaY, event.deltaMode, cellHeight, this.terminal.rows);
Comment on lines +326 to +327
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Nullish coalescing doesn't guard against cellHeight of 0.

The ?? operator only falls back for null/undefined, not for 0. If the renderer dimensions return height: 0 (e.g., before terminal fully renders), cellHeight will be 0, potentially causing division-by-zero issues in getWheelLineDelta.

The pattern in fitaddon.ts (lines 80-82) explicitly checks if (dims.css.cell.height === 0) and returns early. Consider a similar guard here, or use a logical OR with a truthy fallback.

🛡️ Suggested fix
-            const cellHeight = (this.terminal as any)?._core?._renderService?.dimensions?.css?.cell?.height ?? 16;
+            const cellHeight = (this.terminal as any)?._core?._renderService?.dimensions?.css?.cell?.height || 16;

Alternatively, add an explicit guard:

             const cellHeight = (this.terminal as any)?._core?._renderService?.dimensions?.css?.cell?.height ?? 16;
+            if (cellHeight === 0) {
+                return;
+            }
             const lineDelta = getWheelLineDelta(event.deltaY, event.deltaMode, cellHeight, this.terminal.rows);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const cellHeight = (this.terminal as any)?._core?._renderService?.dimensions?.css?.cell?.height ?? 16;
const lineDelta = getWheelLineDelta(event.deltaY, event.deltaMode, cellHeight, this.terminal.rows);
const cellHeight = (this.terminal as any)?._core?._renderService?.dimensions?.css?.cell?.height || 16;
const lineDelta = getWheelLineDelta(event.deltaY, event.deltaMode, cellHeight, this.terminal.rows);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/view/term/termwrap.ts` around lines 330 - 331, The current
cellHeight calculation using nullish coalescing can still yield 0 which can
cause division-by-zero in getWheelLineDelta; modify the logic around the
cellHeight variable (where cellHeight is computed from (this.terminal as
any)?._core?._renderService?.dimensions?.css?.cell?.height) to explicitly guard
against 0 — either return early if height === 0 (like the fit addon) or replace
a falsy/zero value with a sensible default (e.g., 16) before calling
getWheelLineDelta; ensure the change references the same symbol names
(cellHeight, this.terminal, getWheelLineDelta) so the guard is applied in that
calculation site.

if (lineDelta === 0) {
return;
}
this.wheelScrollRemainder += lineDelta;
const wholeLines =
this.wheelScrollRemainder > 0
? Math.floor(this.wheelScrollRemainder)
: Math.ceil(this.wheelScrollRemainder);
if (wholeLines === 0) {
event.preventDefault();
event.stopPropagation();
return;
}
this.wheelScrollRemainder -= wholeLines;
this.terminal.scrollLines(wholeLines);
event.preventDefault();
event.stopPropagation();
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
this.connectElem.addEventListener("wheel", wheelHandler, { passive: false, capture: true });
this.toDispose.push({
dispose: () => {
this.connectElem.removeEventListener("wheel", wheelHandler, true);
},
});
this.handleResize();
const pasteHandler = this.pasteHandler.bind(this);
this.connectElem.addEventListener("paste", pasteHandler, true);
Expand Down
Loading