From 53736936a54a0ea1b262f633b34acd64d8921ed7 Mon Sep 17 00:00:00 2001 From: "r.shi" Date: Fri, 8 May 2026 15:08:48 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20when=20click=20terminal=20or=20file?= =?UTF-8?q?s=20in=20widgets=20=EF=BC=8Cuse=20dir=20path=20with=20currenct?= =?UTF-8?q?=20activate=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/workspace/widgets.tsx | 16 +++- package-lock.json | 149 ++++++++++------------------- 2 files changed, 66 insertions(+), 99 deletions(-) diff --git a/frontend/app/workspace/widgets.tsx b/frontend/app/workspace/widgets.tsx index f11eca91da..32a829fe8f 100644 --- a/frontend/app/workspace/widgets.tsx +++ b/frontend/app/workspace/widgets.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Tooltip } from "@/app/element/tooltip"; +import { getFocusedBlockId, globalStore, WOS } from "@/app/store/global"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useWaveEnv, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { shouldIncludeWidgetForWorkspace } from "@/app/workspace/widgetfilter"; @@ -56,7 +57,20 @@ type WidgetPropsType = { }; async function handleWidgetSelect(widget: WidgetConfigType, env: WidgetsEnv) { - const blockDef = widget.blockdef; + let blockDef = widget.blockdef; + const focusedBlockId = getFocusedBlockId(); + if (focusedBlockId) { + const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", focusedBlockId)); + const blockData = globalStore.get(blockAtom); + const cwd = blockData?.meta?.["cmd:cwd"]; + if (cwd) { + if (blockDef?.meta?.view === "preview" && blockDef?.meta?.file) { + blockDef = { ...blockDef, meta: { ...blockDef.meta, file: cwd } }; + } else if (blockDef?.meta?.view === "term" && blockDef?.meta?.controller === "shell") { + blockDef = { ...blockDef, meta: { ...blockDef.meta, "cmd:cwd": cwd } }; + } + } + } env.createBlock(blockDef, widget.magnified); } diff --git a/package-lock.json b/package-lock.json index 1798a0fa38..09bd316558 100644 --- a/package-lock.json +++ b/package-lock.json @@ -178,6 +178,7 @@ "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -198,6 +199,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -457,6 +459,7 @@ "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.37.0.tgz", "integrity": "sha512-DAFVUvEg+u7jUs6BZiVz9zdaUebYULPiQ4LM2R4n8Nujzyj7BZzGr2DCd85ip4p/cx7nAZWKM8pLcGtkTRTdsg==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.37.0", "@algolia/requester-browser-xhr": "5.37.0", @@ -610,6 +613,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2468,6 +2472,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2490,6 +2495,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2599,6 +2605,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3020,6 +3027,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -4071,6 +4079,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.2.tgz", "integrity": "sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==", "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/logger": "3.9.2", @@ -4833,7 +4842,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -4855,7 +4863,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -5589,7 +5596,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -5613,7 +5619,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -5637,7 +5642,6 @@ "os": [ "darwin" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -5655,7 +5659,6 @@ "os": [ "darwin" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -5673,7 +5676,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -5691,7 +5693,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -5709,7 +5710,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -5727,7 +5727,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -5745,7 +5744,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -5763,7 +5761,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -5781,7 +5778,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -5799,7 +5795,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -5823,7 +5818,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -5847,7 +5841,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -5871,7 +5864,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -5895,7 +5887,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -5919,7 +5910,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -5943,7 +5933,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -5964,7 +5953,6 @@ "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/runtime": "^1.4.4" }, @@ -5988,7 +5976,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -6009,7 +5996,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -6030,7 +6016,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -6407,6 +6392,7 @@ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "license": "MIT", + "peer": true, "dependencies": { "@types/mdx": "^2.0.0" }, @@ -8248,6 +8234,7 @@ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -8642,7 +8629,8 @@ "version": "0.0.7", "resolved": "https://registry.npmjs.org/@table-nav/core/-/core-0.0.7.tgz", "integrity": "sha512-pCh18jHDRe3tw9sJZXfKi4cSD6VjHbn40CYdqhp5X91SIX7rakDEQAsTx6F7Fv9TUv265l+5rUDcYNaJ0N0cqQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@table-nav/react": { "version": "0.0.7", @@ -9664,6 +9652,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.2.tgz", "integrity": "sha512-lif9hF9afNk39jMUVYk5eyYEojLZQqaYX61LfuwUJJ1+qiQbh7jVaZXskYgzyjAIFDFQRf5Sd6MVM7EyXkfiRw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -9739,6 +9728,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -10020,6 +10010,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -10714,6 +10705,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10809,6 +10801,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -10873,6 +10866,7 @@ "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.37.0.tgz", "integrity": "sha512-y7gau/ZOQDqoInTQp0IwTOjkrHc4Aq4R8JgpmCleFwiLl+PbN2DMWoDUWZnrK8AhNJwT++dn28Bt4NZYNLAmuA==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.3.0", "@algolia/client-abtesting": "5.37.0", @@ -11829,6 +11823,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -12337,6 +12332,7 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.1.tgz", "integrity": "sha512-f0yv5CPKaFxfsPTBzX7vGuim4oIC1/gcS7LUGdBSwl2dU6+FON6LVUksdOo1qJjoUvXNn45urgh8C+0a24pACQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.1.1", "@chevrotain/gast": "11.1.1", @@ -13196,8 +13192,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -13365,6 +13360,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13695,6 +13691,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -14104,6 +14101,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -14549,6 +14547,7 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -15054,7 +15053,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -15075,7 +15073,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -15091,7 +15088,6 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", - "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -15102,7 +15098,6 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4.0.0" } @@ -15432,6 +15427,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -20284,6 +20280,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -22598,7 +22595,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -23407,7 +23403,8 @@ "version": "2.14.0", "resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.14.0.tgz", "integrity": "sha512-RjV0pqc79kYhQLC3vTcLRb5GLpI1n6qh0Oua3g+bGH4EgNOJHVBGP7u0zZtxoAa0dkHlAqTTSYRb9MMmxNLjig==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/overlayscrollbars-react": { "version": "0.5.6", @@ -24187,6 +24184,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -25090,6 +25088,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -25689,7 +25688,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -25707,7 +25705,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -25808,6 +25805,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -25959,6 +25957,7 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -26186,6 +26185,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -26234,6 +26234,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -26307,6 +26308,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz", "integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/react": "*" }, @@ -26372,6 +26374,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -28120,7 +28123,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -28135,7 +28137,6 @@ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -28157,7 +28158,6 @@ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -28196,6 +28196,7 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -28365,6 +28366,7 @@ "resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz", "integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==", "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -28483,6 +28485,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -28874,61 +28877,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", @@ -29812,7 +29760,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", @@ -29840,7 +29787,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=16" } @@ -29968,7 +29914,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -30487,7 +30432,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsunami-frontend": { "resolved": "tsunami/frontend", @@ -31068,6 +31014,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -31166,6 +31113,7 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", + "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -32048,6 +31996,7 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -32208,6 +32157,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -32450,6 +32400,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -33478,6 +33429,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -33527,7 +33479,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "tsunami/frontend/node_modules/redux-thunk": { "version": "3.1.0", From 8f315fb84f7f818439d6548775317a54ded31869 Mon Sep 17 00:00:00 2001 From: "r.shi" Date: Fri, 8 May 2026 17:32:17 +0800 Subject: [PATCH 2/8] feat: git changes wegets --- frontend/app/block/blockregistry.ts | 2 + frontend/app/block/blockutil.tsx | 6 + frontend/app/store/wshclientapi.ts | 6 + frontend/app/view/gitstatus/gitstatus.tsx | 287 ++++++++++++++++++++++ frontend/app/workspace/widgets.tsx | 4 +- frontend/types/gotypes.d.ts | 19 ++ pkg/wconfig/defaultconfig/widgets.json | 11 + pkg/wshrpc/wshclient/wshclient.go | 6 + pkg/wshrpc/wshremote/gitstatus.go | 74 ++++++ pkg/wshrpc/wshrpctypes.go | 16 ++ 10 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 frontend/app/view/gitstatus/gitstatus.tsx create mode 100644 pkg/wshrpc/wshremote/gitstatus.go diff --git a/frontend/app/block/blockregistry.ts b/frontend/app/block/blockregistry.ts index 5de7e05bd3..a8c27feeff 100644 --- a/frontend/app/block/blockregistry.ts +++ b/frontend/app/block/blockregistry.ts @@ -4,6 +4,7 @@ import { BlockNodeModel } from "@/app/block/blocktypes"; import type { TabModel } from "@/app/store/tab-model"; import { AiFileDiffViewModel } from "@/app/view/aifilediff/aifilediff"; +import { GitStatusViewModel } from "@/app/view/gitstatus/gitstatus"; import { LauncherViewModel } from "@/app/view/launcher/launcher"; import { PreviewModel } from "@/app/view/preview/preview-model"; import { ProcessViewerViewModel } from "@/app/view/processviewer/processviewer"; @@ -35,6 +36,7 @@ BlockRegistry.set("tsunami", TsunamiViewModel); BlockRegistry.set("aifilediff", AiFileDiffViewModel); BlockRegistry.set("waveconfig", WaveConfigViewModel); BlockRegistry.set("processviewer", ProcessViewerViewModel); +BlockRegistry.set("gitstatus", GitStatusViewModel); function makeDefaultViewModel(viewType: string): ViewModel { const viewModel: ViewModel = { diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx index 3ef4d39821..bf48cc4c34 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 == "gitstatus") { + return "code-branch"; + } return "square"; } @@ -73,6 +76,9 @@ export function blockViewToName(view: string): string { if (view == "processviewer") { return "Processes"; } + if (view == "gitstatus") { + return "Git Status"; + } return view; } diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 8482be260d..8cefcaa9e8 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -738,6 +738,12 @@ export class RpcApiType { return client.wshRpcCall("remotegetinfo", null, opts); } + // command "remotegitstatus" [call] + RemoteGitStatusCommand(client: WshClient, data: CommandRemoteGitStatusData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotegitstatus", data, opts); + return client.wshRpcCall("remotegitstatus", data, opts); + } + // command "remoteinstallrcfiles" [call] RemoteInstallRcFilesCommand(client: WshClient, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remoteinstallrcfiles", null, opts); diff --git a/frontend/app/view/gitstatus/gitstatus.tsx b/frontend/app/view/gitstatus/gitstatus.tsx new file mode 100644 index 0000000000..388aaf698a --- /dev/null +++ b/frontend/app/view/gitstatus/gitstatus.tsx @@ -0,0 +1,287 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { globalStore } from "@/app/store/jotaiStore"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { MetaKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; +import { isBlank, makeConnRoute } from "@/util/util"; +import * as jotai from "jotai"; +import * as React from "react"; + +type GitStatusEnv = WaveEnvSubset<{ + rpc: { + RemoteGitStatusCommand: WaveEnv["rpc"]["RemoteGitStatusCommand"]; + }; + getConnStatusAtom: WaveEnv["getConnStatusAtom"]; + getBlockMetaKeyAtom: MetaKeyAtomFnType<"connection" | "cmd:cwd">; + createBlock: WaveEnv["createBlock"]; +}>; + +const StatusLabels: Record = { + M: { label: "M", color: "text-yellow-400" }, + A: { label: "A", color: "text-green-400" }, + D: { label: "D", color: "text-red-400" }, + R: { label: "R", color: "text-blue-400" }, + C: { label: "C", color: "text-blue-400" }, + U: { label: "U", color: "text-orange-400" }, + "??": { label: "?", color: "text-gray-400" }, +}; + +function getStatusInfo(status: string): { label: string; color: string } { + return StatusLabels[status] ?? { label: status, color: "text-secondary" }; +} + +export class GitStatusViewModel implements ViewModel { + viewType: string; + blockId: string; + env: GitStatusEnv; + + viewIcon = jotai.atom("code-branch"); + viewName = jotai.atom("Git Status"); + manageConnection = jotai.atom(true); + noPadding = jotai.atom(true); + + filesAtom: jotai.PrimitiveAtom; + branchAtom: jotai.PrimitiveAtom; + errorAtom: jotai.PrimitiveAtom; + loadingAtom: jotai.PrimitiveAtom; + + connection: jotai.Atom; + cwd: jotai.Atom; + connStatus: jotai.Atom; + + disposed = false; + cancelPoll: (() => void) | null = null; + fetchEpoch = 0; + + constructor({ blockId, waveEnv }: ViewModelInitType) { + this.viewType = "gitstatus"; + this.blockId = blockId; + this.env = waveEnv; + + this.filesAtom = jotai.atom([]) as jotai.PrimitiveAtom; + this.branchAtom = jotai.atom("") as jotai.PrimitiveAtom; + this.errorAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.loadingAtom = jotai.atom(true) as jotai.PrimitiveAtom; + + this.connection = jotai.atom((get) => { + const connValue = get(this.env.getBlockMetaKeyAtom(blockId, "connection")); + if (isBlank(connValue)) { + return "local"; + } + return connValue; + }); + this.cwd = jotai.atom((get) => { + return get(this.env.getBlockMetaKeyAtom(blockId, "cmd:cwd")) ?? ""; + }); + this.connStatus = jotai.atom((get) => { + const connName = get(this.env.getBlockMetaKeyAtom(blockId, "connection")); + const connAtom = this.env.getConnStatusAtom(connName); + return get(connAtom); + }); + + this.startPolling(); + } + + get viewComponent(): ViewComponent { + return GitStatusView; + } + + async doOneFetch() { + if (this.disposed) return; + const epoch = ++this.fetchEpoch; + const conn = globalStore.get(this.connection); + const cwd = globalStore.get(this.cwd); + const connStatus = globalStore.get(this.connStatus); + + if (!connStatus?.connected || isBlank(cwd)) { + return; + } + + const route = makeConnRoute(conn); + try { + const resp = await this.env.rpc.RemoteGitStatusCommand( + TabRpcClient, + { cwd }, + { route } + ); + if (this.disposed || this.fetchEpoch !== epoch) return; + + if (resp.error) { + globalStore.set(this.errorAtom, resp.error); + globalStore.set(this.filesAtom, []); + globalStore.set(this.branchAtom, ""); + } else { + globalStore.set(this.errorAtom, null); + globalStore.set(this.filesAtom, resp.files ?? []); + globalStore.set(this.branchAtom, resp.branch ?? ""); + } + globalStore.set(this.loadingAtom, false); + } catch (e) { + if (this.disposed || this.fetchEpoch !== epoch) return; + globalStore.set(this.errorAtom, String(e)); + globalStore.set(this.loadingAtom, false); + } + } + + startPolling() { + let cancelled = false; + this.cancelPoll = () => { + cancelled = true; + }; + + const poll = async () => { + while (!cancelled && !this.disposed) { + await this.doOneFetch(); + if (cancelled || this.disposed) break; + + await new Promise((resolve) => { + const timer = setTimeout(resolve, 3000); + this.cancelPoll = () => { + clearTimeout(timer); + cancelled = true; + resolve(); + }; + }); + + if (!cancelled) { + this.cancelPoll = () => { + cancelled = true; + }; + } + } + }; + + poll(); + } + + dispose() { + this.disposed = true; + this.cancelPoll?.(); + } + + openFile(filePath: string) { + const cwd = globalStore.get(this.cwd); + const conn = globalStore.get(this.connection); + const fullPath = cwd + "/" + filePath; + const meta: Record = { + view: "preview", + file: fullPath, + }; + if (conn !== "local") { + meta.connection = conn; + } + this.env.createBlock({ meta }); + } + + openDiff(filePath: string) { + const cwd = globalStore.get(this.cwd); + const conn = globalStore.get(this.connection); + const meta: Record = { + view: "term", + controller: "cmd", + cmd: `git diff -- "${filePath}"`, + "cmd:cwd": cwd, + "cmd:runonstart": true, + "cmd:clearonstart": true, + }; + if (conn !== "local") { + meta.connection = conn; + } + this.env.createBlock({ meta }); + } +} + +const GitStatusFileRow = React.memo( + ({ file, model }: { file: GitStatusFile; model: GitStatusViewModel }) => { + const statusInfo = getStatusInfo(file.status); + + return ( +
model.openFile(file.file)} + onDoubleClick={() => model.openDiff(file.file)} + > + + {statusInfo.label} + + + {file.file} + + +
+ ); + } +); +GitStatusFileRow.displayName = "GitStatusFileRow"; + +export const GitStatusView: React.FC> = React.memo( + function GitStatusView({ model }) { + const files = jotai.useAtomValue(model.filesAtom); + const branch = jotai.useAtomValue(model.branchAtom); + const error = jotai.useAtomValue(model.errorAtom); + const loading = jotai.useAtomValue(model.loadingAtom); + const cwd = jotai.useAtomValue(model.cwd); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ + {error} +
+ ); + } + + if (isBlank(cwd)) { + return ( +
+ No working directory set +
+ ); + } + + return ( +
+ {branch && ( +
+ + {branch} + {files.length} changed +
+ )} +
+ {files.length === 0 ? ( +
+ Working tree clean +
+ ) : ( + files.map((file, idx) => ( + + )) + )} +
+
+ {cwd} +
+
+ ); + } +); diff --git a/frontend/app/workspace/widgets.tsx b/frontend/app/workspace/widgets.tsx index 32a829fe8f..c367b19611 100644 --- a/frontend/app/workspace/widgets.tsx +++ b/frontend/app/workspace/widgets.tsx @@ -66,7 +66,9 @@ async function handleWidgetSelect(widget: WidgetConfigType, env: WidgetsEnv) { if (cwd) { if (blockDef?.meta?.view === "preview" && blockDef?.meta?.file) { blockDef = { ...blockDef, meta: { ...blockDef.meta, file: cwd } }; - } else if (blockDef?.meta?.view === "term" && blockDef?.meta?.controller === "shell") { + } else if (blockDef?.meta?.view === "term") { + blockDef = { ...blockDef, meta: { ...blockDef.meta, "cmd:cwd": cwd } }; + } else if (blockDef?.meta?.view === "gitstatus") { blockDef = { ...blockDef, meta: { ...blockDef.meta, "cmd:cwd": cwd } }; } } diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index c5b870d7ed..3f03d785cd 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -546,6 +546,11 @@ declare global { streammeta: StreamMeta; }; + // wshrpc.CommandRemoteGitStatusData + type CommandRemoteGitStatusData = { + cwd: string; + }; + // wshrpc.CommandRemoteListEntriesData type CommandRemoteListEntriesData = { path: string; @@ -1024,6 +1029,19 @@ declare global { buildtime: string; }; + // wshrpc.GitStatusFile + type GitStatusFile = { + status: string; + file: string; + }; + + // wshrpc.GitStatusResponse + type GitStatusResponse = { + branch: string; + files: GitStatusFile[]; + error?: string; + }; + // waveobj.Job type Job = WaveObj & { connection: string; @@ -1589,6 +1607,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/pkg/wconfig/defaultconfig/widgets.json b/pkg/wconfig/defaultconfig/widgets.json index eb978d6448..9d2999ecae 100644 --- a/pkg/wconfig/defaultconfig/widgets.json +++ b/pkg/wconfig/defaultconfig/widgets.json @@ -50,5 +50,16 @@ "view": "processviewer" } } + }, + "defwidget@gitchanges": { + "display:order": 0, + "icon": "code-branch", + "label": "changes", + "description": "Git changed files - click to open, double-click for diff", + "blockdef": { + "meta": { + "view": "gitstatus" + } + } } } diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index d5333aec2b..7356fb17b8 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -736,6 +736,12 @@ func RemoteGetInfoCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (wshrpc.Remot return resp, err } +// command "remotegitstatus", wshserver.RemoteGitStatusCommand +func RemoteGitStatusCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteGitStatusData, opts *wshrpc.RpcOpts) (*wshrpc.GitStatusResponse, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.GitStatusResponse](w, "remotegitstatus", data, opts) + return resp, err +} + // command "remoteinstallrcfiles", wshserver.RemoteInstallRcFilesCommand func RemoteInstallRcFilesCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "remoteinstallrcfiles", nil, opts) diff --git a/pkg/wshrpc/wshremote/gitstatus.go b/pkg/wshrpc/wshremote/gitstatus.go new file mode 100644 index 0000000000..f002c90a68 --- /dev/null +++ b/pkg/wshrpc/wshremote/gitstatus.go @@ -0,0 +1,74 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshremote + +import ( + "bufio" + "context" + "fmt" + "os/exec" + "strings" + + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +func (impl *ServerImpl) RemoteGitStatusCommand(ctx context.Context, data wshrpc.CommandRemoteGitStatusData) (*wshrpc.GitStatusResponse, error) { + cwd := data.Cwd + if cwd == "" { + return nil, fmt.Errorf("cwd is required") + } + + branch, err := getGitBranch(ctx, cwd) + if err != nil { + return &wshrpc.GitStatusResponse{Error: err.Error()}, nil + } + + files, err := getGitStatusFiles(ctx, cwd) + if err != nil { + return &wshrpc.GitStatusResponse{Error: err.Error()}, nil + } + + return &wshrpc.GitStatusResponse{ + Branch: branch, + Files: files, + }, nil +} + +func getGitBranch(ctx context.Context, cwd string) (string, error) { + cmd := exec.CommandContext(ctx, "git", "branch", "--show-current") + cmd.Dir = cwd + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("not a git repository or git not available") + } + return strings.TrimSpace(string(out)), nil +} + +func getGitStatusFiles(ctx context.Context, cwd string) ([]wshrpc.GitStatusFile, error) { + cmd := exec.CommandContext(ctx, "git", "status", "--porcelain") + cmd.Dir = cwd + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git status failed: %v", err) + } + + var files []wshrpc.GitStatusFile + scanner := bufio.NewScanner(strings.NewReader(string(out))) + for scanner.Scan() { + line := scanner.Text() + if len(line) < 4 { + continue + } + status := strings.TrimSpace(line[:2]) + file := line[3:] + if idx := strings.Index(file, " -> "); idx >= 0 { + file = file[idx+4:] + } + files = append(files, wshrpc.GitStatusFile{ + Status: status, + File: file, + }) + } + return files, nil +} diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 51e2338ba8..d1264952f5 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -129,6 +129,7 @@ type WshRpcInterface interface { BadgeWatchPidCommand(ctx context.Context, data CommandBadgeWatchPidData) error RemoteProcessListCommand(ctx context.Context, data CommandRemoteProcessListData) (*ProcessListResponse, error) RemoteProcessSignalCommand(ctx context.Context, data CommandRemoteProcessSignalData) error + RemoteGitStatusCommand(ctx context.Context, data CommandRemoteGitStatusData) (*GitStatusResponse, error) // emain WebSelectorCommand(ctx context.Context, data CommandWebSelectorData) ([]string, error) @@ -925,3 +926,18 @@ type CommandRemoteProcessSignalData struct { Pid int32 `json:"pid"` Signal string `json:"signal"` } + +type CommandRemoteGitStatusData struct { + Cwd string `json:"cwd"` +} + +type GitStatusFile struct { + Status string `json:"status"` + File string `json:"file"` +} + +type GitStatusResponse struct { + Branch string `json:"branch"` + Files []GitStatusFile `json:"files"` + Error string `json:"error,omitempty"` +} From ec0d1a73ca13dc2ca07975203eeb81f9ab0cb629 Mon Sep 17 00:00:00 2001 From: "r.shi" Date: Fri, 8 May 2026 18:13:12 +0800 Subject: [PATCH 3/8] feat: add git gutter indicators and line-level diff RPC Add RemoteGitLineDiffCommand RPC that parses `git diff HEAD --unified=0` output into line-level hunk data (added/modified/deleted ranges). Integrate with Monaco editor in file preview to show colored gutter indicators: green for added lines, blue for modified, red for deleted. Also add a simplified GitHub Actions release workflow for cross-platform builds without code signing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 97 +++++++++++++++++++++ frontend/app/store/wshclientapi.ts | 6 ++ frontend/app/view/codeeditor/codeeditor.tsx | 1 + frontend/app/view/preview/preview-edit.tsx | 56 +++++++++++- frontend/tailwindsetup.css | 24 +++++ frontend/types/gotypes.d.ts | 19 ++++ pkg/wshrpc/wshclient/wshclient.go | 6 ++ pkg/wshrpc/wshremote/gitstatus.go | 78 +++++++++++++++++ pkg/wshrpc/wshrpctypes.go | 17 ++++ 9 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..fd58c5251e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,97 @@ +name: Release +run-name: Release ${{ github.ref_name }} +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+*" +env: + GO_VERSION: "1.25.6" + NODE_VERSION: 22 + NODE_OPTIONS: --max-old-space-size=4096 +jobs: + build-app: + strategy: + matrix: + include: + - platform: "darwin" + runner: "macos-latest" + - platform: "linux" + runner: "ubuntu-latest" + - platform: "windows" + runner: "windows-latest" + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - name: Install Linux Build Dependencies + if: matrix.platform == 'linux' + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y libarchive-tools libopenjp2-tools rpm squashfs-tools + - name: Install Zig (not Mac) + if: matrix.platform != 'darwin' + uses: mlugg/setup-zig@v2 + - name: Install FPM (not Windows) + if: matrix.platform != 'windows' + run: sudo gem install fpm + - name: Install FPM (Windows only) + if: matrix.platform == 'windows' + run: gem install fpm + - uses: actions/setup-go@v5 + with: + go-version: ${{env.GO_VERSION}} + cache-dependency-path: go.sum + - uses: actions/setup-node@v4 + with: + node-version: ${{env.NODE_VERSION}} + cache: npm + cache-dependency-path: package-lock.json + - name: Force git deps to HTTPS + run: | + git config --global url.https://github.com/.insteadof ssh://git@github.com/ + git config --global url.https://github.com/.insteadof git@github.com: + - name: npm ci + run: npm ci --no-audit --no-fund + env: + GIT_ASKPASS: "echo" + GIT_TERMINAL_PROMPT: "0" + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Build + run: task package + env: + USE_SYSTEM_FPM: true + shell: bash + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.platform }}-artifacts + path: make/ + + create-release: + runs-on: ubuntu-latest + needs: build-app + permissions: + contents: write + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + name: Wave Terminal ${{ github.ref_name }} + generate_release_notes: true + draft: false + files: | + artifacts/*.zip + artifacts/*.dmg + artifacts/*.exe + artifacts/*.msi + artifacts/*.rpm + artifacts/*.deb + artifacts/*.AppImage diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 8cefcaa9e8..16b98a2577 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -738,6 +738,12 @@ export class RpcApiType { return client.wshRpcCall("remotegetinfo", null, opts); } + // command "remotegitlinediff" [call] + RemoteGitLineDiffCommand(client: WshClient, data: CommandRemoteGitLineDiffData, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotegitlinediff", data, opts); + return client.wshRpcCall("remotegitlinediff", data, opts); + } + // command "remotegitstatus" [call] RemoteGitStatusCommand(client: WshClient, data: CommandRemoteGitStatusData, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "remotegitstatus", data, opts); diff --git a/frontend/app/view/codeeditor/codeeditor.tsx b/frontend/app/view/codeeditor/codeeditor.tsx index ba1e28666e..8032bd7a1a 100644 --- a/frontend/app/view/codeeditor/codeeditor.tsx +++ b/frontend/app/view/codeeditor/codeeditor.tsx @@ -14,6 +14,7 @@ function defaultEditorOptions(): MonacoTypes.editor.IEditorOptions { fontSize: 12, fontFamily: "Hack", smoothScrolling: true, + glyphMargin: true, scrollbar: { useShadows: false, verticalScrollbarSize: 5, diff --git a/frontend/app/view/preview/preview-edit.tsx b/frontend/app/view/preview/preview-edit.tsx index 2961771fa3..4d413f6af8 100644 --- a/frontend/app/view/preview/preview-edit.tsx +++ b/frontend/app/view/preview/preview-edit.tsx @@ -1,16 +1,19 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { globalStore } from "@/app/store/jotaiStore"; import { tryReinjectKey } from "@/app/store/keymodel"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; -import { fireAndForget } from "@/util/util"; +import { fireAndForget, makeConnRoute } from "@/util/util"; import { useAtomValue, useSetAtom } from "jotai"; import type * as MonacoTypes from "monaco-editor"; import * as monaco from "monaco-editor"; import { useEffect } from "react"; import type { SpecializedViewProps } from "./preview"; +import type { PreviewModel } from "./preview-model"; export const shellFileMap: Record = { ".bashrc": "shell", @@ -36,6 +39,51 @@ export const shellFileMap: Record = { ".gvimrc": "shell", }; +async function applyGitGutter( + editor: MonacoTypes.editor.IStandaloneCodeEditor, + decorationCollection: MonacoTypes.editor.IEditorDecorationsCollection, + model: PreviewModel +) { + try { + const blockData = globalStore.get(model.blockAtom); + const filePath = blockData?.meta?.file; + const connName = blockData?.meta?.connection; + if (!filePath) return; + + const cwd = filePath.substring(0, filePath.lastIndexOf("/")) || "/"; + const route = makeConnRoute(connName); + + const resp = await RpcApi.RemoteGitLineDiffCommand(TabRpcClient, { cwd, file: filePath }, { route }); + if (!resp || resp.error || !resp.hunks?.length) return; + + const decorations: MonacoTypes.editor.IModelDeltaDecoration[] = resp.hunks.map((hunk) => { + let glyphClass: string; + if (hunk.type === "added") { + glyphClass = "git-gutter-added"; + } else if (hunk.type === "modified") { + glyphClass = "git-gutter-modified"; + } else { + glyphClass = "git-gutter-deleted"; + } + return { + range: new monaco.Range(hunk.startline, 1, hunk.endline, 1), + options: { + isWholeLine: true, + glyphMarginClassName: glyphClass, + overviewRuler: { + color: hunk.type === "added" ? "#2ea04370" : hunk.type === "modified" ? "#0078d470" : "#f8514970", + position: monaco.editor.OverviewRulerLane.Left, + }, + }, + }; + }); + + decorationCollection.set(decorations); + } catch { + // silently ignore - file might not be in a git repo + } +} + function CodeEditPreview({ model }: SpecializedViewProps) { const fileContent = useAtomValue(model.fileContent); const setNewFileContent = useSetAtom(model.newFileContent); @@ -90,8 +138,12 @@ function CodeEditPreview({ model }: SpecializedViewProps) { editor.focus(); } + const decorationCollection = editor.createDecorationsCollection([]); + applyGitGutter(editor, decorationCollection, model); + return () => { keyDownDisposer.dispose(); + decorationCollection.clear(); }; } diff --git a/frontend/tailwindsetup.css b/frontend/tailwindsetup.css index 3a0523c8ce..ff8d22122e 100644 --- a/frontend/tailwindsetup.css +++ b/frontend/tailwindsetup.css @@ -125,3 +125,27 @@ svg [aria-label="tip"] g path { opacity: 0; } } + +/* Git gutter indicators for Monaco editor */ +.git-gutter-added { + background: #2ea043; + width: 3px !important; + margin-left: 5px; + border-radius: 1px; +} + +.git-gutter-modified { + background: #0078d4; + width: 3px !important; + margin-left: 5px; + border-radius: 1px; +} + +.git-gutter-deleted { + background: #f85149; + width: 0 !important; + margin-left: 3px; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid #f85149; +} diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 3f03d785cd..0fb663d6b2 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -546,6 +546,12 @@ declare global { streammeta: StreamMeta; }; + // wshrpc.CommandRemoteGitLineDiffData + type CommandRemoteGitLineDiffData = { + cwd: string; + file: string; + }; + // wshrpc.CommandRemoteGitStatusData type CommandRemoteGitStatusData = { cwd: string; @@ -1029,6 +1035,19 @@ declare global { buildtime: string; }; + // wshrpc.GitLineDiffHunk + type GitLineDiffHunk = { + type: string; + startline: number; + endline: number; + }; + + // wshrpc.GitLineDiffResponse + type GitLineDiffResponse = { + hunks: GitLineDiffHunk[]; + error?: string; + }; + // wshrpc.GitStatusFile type GitStatusFile = { status: string; diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 7356fb17b8..f1a6fbb0a6 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -736,6 +736,12 @@ func RemoteGetInfoCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (wshrpc.Remot return resp, err } +// command "remotegitlinediff", wshserver.RemoteGitLineDiffCommand +func RemoteGitLineDiffCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteGitLineDiffData, opts *wshrpc.RpcOpts) (*wshrpc.GitLineDiffResponse, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.GitLineDiffResponse](w, "remotegitlinediff", data, opts) + return resp, err +} + // command "remotegitstatus", wshserver.RemoteGitStatusCommand func RemoteGitStatusCommand(w *wshutil.WshRpc, data wshrpc.CommandRemoteGitStatusData, opts *wshrpc.RpcOpts) (*wshrpc.GitStatusResponse, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.GitStatusResponse](w, "remotegitstatus", data, opts) diff --git a/pkg/wshrpc/wshremote/gitstatus.go b/pkg/wshrpc/wshremote/gitstatus.go index f002c90a68..7c058a429a 100644 --- a/pkg/wshrpc/wshremote/gitstatus.go +++ b/pkg/wshrpc/wshremote/gitstatus.go @@ -8,6 +8,9 @@ import ( "context" "fmt" "os/exec" + "path/filepath" + "regexp" + "strconv" "strings" "github.com/wavetermdev/waveterm/pkg/wshrpc" @@ -72,3 +75,78 @@ func getGitStatusFiles(ctx context.Context, cwd string) ([]wshrpc.GitStatusFile, } return files, nil } + +var hunkHeaderRegex = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`) + +func (impl *ServerImpl) RemoteGitLineDiffCommand(ctx context.Context, data wshrpc.CommandRemoteGitLineDiffData) (*wshrpc.GitLineDiffResponse, error) { + if data.Cwd == "" || data.File == "" { + return nil, fmt.Errorf("cwd and file are required") + } + + relPath := data.File + if filepath.IsAbs(relPath) { + rel, err := filepath.Rel(data.Cwd, relPath) + if err == nil { + relPath = rel + } + } + + cmd := exec.CommandContext(ctx, "git", "diff", "HEAD", "--unified=0", "--", relPath) + cmd.Dir = data.Cwd + out, err := cmd.Output() + if err != nil { + exitErr, ok := err.(*exec.ExitError) + if ok && exitErr.ExitCode() == 1 { + // diff returns 1 when there are differences - that's fine, use stdout + } else { + return &wshrpc.GitLineDiffResponse{Error: fmt.Sprintf("git diff failed: %v", err)}, nil + } + } + + hunks := parseUnifiedDiffHunks(string(out)) + return &wshrpc.GitLineDiffResponse{Hunks: hunks}, nil +} + +func parseUnifiedDiffHunks(diffOutput string) []wshrpc.GitLineDiffHunk { + var hunks []wshrpc.GitLineDiffHunk + scanner := bufio.NewScanner(strings.NewReader(diffOutput)) + + for scanner.Scan() { + line := scanner.Text() + matches := hunkHeaderRegex.FindStringSubmatch(line) + if matches == nil { + continue + } + + oldCount := 1 + if matches[2] != "" { + oldCount, _ = strconv.Atoi(matches[2]) + } + newStart, _ := strconv.Atoi(matches[3]) + newCount := 1 + if matches[4] != "" { + newCount, _ = strconv.Atoi(matches[4]) + } + + if oldCount == 0 && newCount > 0 { + hunks = append(hunks, wshrpc.GitLineDiffHunk{ + Type: "added", + StartLine: newStart, + EndLine: newStart + newCount - 1, + }) + } else if newCount == 0 && oldCount > 0 { + hunks = append(hunks, wshrpc.GitLineDiffHunk{ + Type: "deleted", + StartLine: newStart, + EndLine: newStart, + }) + } else { + hunks = append(hunks, wshrpc.GitLineDiffHunk{ + Type: "modified", + StartLine: newStart, + EndLine: newStart + newCount - 1, + }) + } + } + return hunks +} diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index d1264952f5..64c8b8d90c 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -130,6 +130,7 @@ type WshRpcInterface interface { RemoteProcessListCommand(ctx context.Context, data CommandRemoteProcessListData) (*ProcessListResponse, error) RemoteProcessSignalCommand(ctx context.Context, data CommandRemoteProcessSignalData) error RemoteGitStatusCommand(ctx context.Context, data CommandRemoteGitStatusData) (*GitStatusResponse, error) + RemoteGitLineDiffCommand(ctx context.Context, data CommandRemoteGitLineDiffData) (*GitLineDiffResponse, error) // emain WebSelectorCommand(ctx context.Context, data CommandWebSelectorData) ([]string, error) @@ -941,3 +942,19 @@ type GitStatusResponse struct { Files []GitStatusFile `json:"files"` Error string `json:"error,omitempty"` } + +type CommandRemoteGitLineDiffData struct { + Cwd string `json:"cwd"` + File string `json:"file"` +} + +type GitLineDiffHunk struct { + Type string `json:"type"` + StartLine int `json:"startline"` + EndLine int `json:"endline"` +} + +type GitLineDiffResponse struct { + Hunks []GitLineDiffHunk `json:"hunks"` + Error string `json:"error,omitempty"` +} From 64321a9d2661bfcaf2516b8cc3deb74733d38f9a Mon Sep 17 00:00:00 2001 From: "r.shi" Date: Fri, 8 May 2026 18:27:56 +0800 Subject: [PATCH 4/8] fix: remove conflicting CI workflows, skip docsite build in release Remove build-helper.yml and publish-release.yml that conflict with our release workflow. Replace `task package` with explicit steps to avoid triggering docsite build which requires sharp module. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build-helper.yml | 209 -------------------------- .github/workflows/publish-release.yml | 96 ------------ .github/workflows/release.yml | 8 +- 3 files changed, 6 insertions(+), 307 deletions(-) delete mode 100644 .github/workflows/build-helper.yml delete mode 100644 .github/workflows/publish-release.yml diff --git a/.github/workflows/build-helper.yml b/.github/workflows/build-helper.yml deleted file mode 100644 index eadb18ce77..0000000000 --- a/.github/workflows/build-helper.yml +++ /dev/null @@ -1,209 +0,0 @@ -# Build Helper workflow - Builds, signs, and packages binaries for each supported platform, then uploads to a staging bucket in S3 for wider distribution. -# For more information on the macOS signing and notarization, see https://www.electron.build/code-signing and https://www.electron.build/configuration/mac -# For more information on the Windows Code Signing, see https://docs.digicert.com/en/digicert-keylocker/ci-cd-integrations/plugins/github-custom-action-for-keypair-signing.html and https://docs.digicert.com/en/digicert-keylocker/signing-tools/sign-authenticode-with-electron-builder-using-ksp-integration.html - -name: Build Helper -run-name: Build ${{ github.ref_name }}${{ github.event_name == 'workflow_dispatch' && ' - Manual' || '' }} -on: - push: - tags: - - "v[0-9]+.[0-9]+.[0-9]+*" - workflow_dispatch: -env: - GO_VERSION: "1.25.6" - NODE_VERSION: 22 - NODE_OPTIONS: --max-old-space-size=4096 -jobs: - build-app: - outputs: - version: ${{ steps.set-version.outputs.WAVETERM_VERSION }} - strategy: - matrix: - include: - - platform: "darwin" - runner: "macos-latest" - - platform: "linux" - runner: "ubuntu-latest" - - platform: "linux" - runner: ubuntu-24.04-arm - - platform: "windows" - runner: "windows-latest" - # - platform: "windows" - # runner: "windows-11-arm64-16core" - runs-on: ${{ matrix.runner }} - steps: - - uses: actions/checkout@v6 - - name: Install Linux Build Dependencies (Linux only) - if: matrix.platform == 'linux' - run: | - sudo apt-get update - sudo apt-get install --no-install-recommends -y libarchive-tools libopenjp2-tools rpm squashfs-tools - sudo snap install snapcraft --classic - sudo snap install lxd - sudo lxd init --auto - sudo snap refresh - - name: Install Zig (not Mac) - if: matrix.platform != 'darwin' - uses: mlugg/setup-zig@v2 - - # The pre-installed version of the AWS CLI has a segfault problem so we'll install it via Homebrew instead. - - name: Upgrade AWS CLI (Mac only) - if: matrix.platform == 'darwin' - run: brew install awscli - - # The version of FPM that comes bundled with electron-builder doesn't include a Linux ARM target. Installing Gems onto the runner is super quick so we'll just do this for all targets. - - name: Install FPM (not Windows) - if: matrix.platform != 'windows' - run: sudo gem install fpm - - name: Install FPM (Windows only) - if: matrix.platform == 'windows' - run: gem install fpm - - # General build dependencies - - uses: actions/setup-go@v6 - with: - go-version: ${{env.GO_VERSION}} - cache-dependency-path: | - go.sum - - uses: actions/setup-node@v6 - with: - node-version: ${{env.NODE_VERSION}} - cache: npm - cache-dependency-path: package-lock.json - - name: Force git deps to HTTPS - run: | - git config --global url.https://github.com/.insteadof ssh://git@github.com/ - git config --global url.https://github.com/.insteadof git@github.com: - - uses: nick-fields/retry@v4 - name: npm ci - with: - command: npm ci --no-audit --no-fund - retry_on: error - max_attempts: 3 - timeout_minutes: 5 - env: - GIT_ASKPASS: "echo" - GIT_TERMINAL_PROMPT: "0" - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - name: "Set Version" - id: set-version - run: echo "WAVETERM_VERSION=$(task version)" >> "$GITHUB_OUTPUT" - shell: bash - - # Windows Code Signing Setup - - name: Set up certificate (Windows only) - if: matrix.platform == 'windows' && github.event_name != 'workflow_dispatch' - run: | - echo "${{ secrets.SM_CLIENT_CERT_FILE_B64 }}" | base64 --decode > /d/Certificate_pkcs12.p12 - shell: bash - - name: Set signing variables (Windows only) - if: matrix.platform == 'windows' && github.event_name != 'workflow_dispatch' - id: variables - run: | - echo "SM_HOST=${{ secrets.SM_HOST }}" >> "$GITHUB_ENV" - echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> "$GITHUB_ENV" - echo "SM_CODE_SIGNING_CERT_SHA1_HASH=${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}" >> "$GITHUB_ENV" - echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_ENV" - echo "SM_CLIENT_CERT_FILE=D:\\Certificate_pkcs12.p12" >> "$GITHUB_OUTPUT" - echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> "$GITHUB_ENV" - echo "C:\Program Files (x86)\Windows Kits\10\App Certification Kit" >> $GITHUB_PATH - echo "C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools" >> $GITHUB_PATH - echo "C:\Program Files\DigiCert\DigiCert Keylocker Tools" >> $GITHUB_PATH - shell: bash - - name: Setup Keylocker KSP (Windows only) - if: matrix.platform == 'windows' && github.event_name != 'workflow_dispatch' - run: | - curl -X GET https://one.digicert.com/signingmanager/api-ui/v1/releases/Keylockertools-windows-x64.msi/download -H "x-api-key:%SM_API_KEY%" -o Keylockertools-windows-x64.msi - msiexec /i Keylockertools-windows-x64.msi /quiet /qn - C:\Windows\System32\certutil.exe -csp "DigiCert Signing Manager KSP" -key -user - smctl windows certsync - shell: cmd - - # Build and upload packages - - name: Build (Linux) - if: matrix.platform == 'linux' - run: task package - env: - USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. - SNAPCRAFT_BUILD_ENVIRONMENT: host - # Retry Darwin build in case of notarization failures - - uses: nick-fields/retry@v4 - name: Build (Darwin) - if: matrix.platform == 'darwin' - with: - command: task package - timeout_minutes: 120 - retry_on: error - max_attempts: 3 - env: - USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. - CSC_LINK: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_CERTIFICATE_2}} - CSC_KEY_PASSWORD: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_CERTIFICATE_PWD_2 }} - APPLE_ID: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_APPLE_ID_2 }} - APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_PWD_2 }} - APPLE_TEAM_ID: ${{ matrix.platform == 'darwin' && secrets.PROD_MACOS_NOTARIZATION_TEAM_ID_2 }} - STATIC_DOCSITE_PATH: ${{env.STATIC_DOCSITE_PATH}} - - name: Build (Windows) - if: matrix.platform == 'windows' - run: task package - env: - USE_SYSTEM_FPM: true # Ensure that the installed version of FPM is used rather than the bundled one. - CSC_LINK: ${{ steps.variables.outputs.SM_CLIENT_CERT_FILE }} - CSC_KEY_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }} - STATIC_DOCSITE_PATH: ${{env.STATIC_DOCSITE_PATH}} - shell: powershell # electron-builder's Windows code signing package has some compatibility issues with pwsh, so we need to use Windows Powershell - - # Upload artifacts to the S3 staging and to the workflow output for the draft release job - - name: Upload to S3 staging - if: github.event_name != 'workflow_dispatch' - run: task artifacts:upload - env: - AWS_ACCESS_KEY_ID: "${{ secrets.ARTIFACTS_KEY_ID }}" - AWS_SECRET_ACCESS_KEY: "${{ secrets.ARTIFACTS_KEY_SECRET }}" - AWS_DEFAULT_REGION: us-west-2 - - name: Upload artifacts - uses: actions/upload-artifact@v5 - with: - name: ${{ matrix.runner }} - path: make - - name: Upload Snapcraft logs on failure - if: failure() - uses: actions/upload-artifact@v5 - with: - name: ${{ matrix.runner }}-log - path: /home/runner/.local/state/snapcraft/log - create-release: - runs-on: ubuntu-latest - needs: build-app - permissions: - contents: write - if: ${{ github.event_name != 'workflow_dispatch' }} - steps: - - name: Download artifacts - uses: actions/download-artifact@v4 - with: - path: make - merge-multiple: true - - name: Create draft release - uses: softprops/action-gh-release@v2 - with: - prerelease: ${{ contains(github.ref_name, '-beta') }} - name: Wave Terminal ${{ github.ref_name }} Release - generate_release_notes: true - draft: true - files: | - make/*.zip - make/*.dmg - make/*.exe - make/*.msi - make/*.rpm - make/*.deb - make/*.pacman - make/*.snap - make/*.flatpak - make/*.AppImage diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml deleted file mode 100644 index 268e37724d..0000000000 --- a/.github/workflows/publish-release.yml +++ /dev/null @@ -1,96 +0,0 @@ -# Workflow to copy artifacts from the staging bucket to the release bucket when a new GitHub Release is published. - -name: Publish Release -run-name: Publish ${{ github.ref_name }} -on: - release: - types: [published] -jobs: - publish-s3: - name: Publish to Releases - if: ${{ startsWith(github.ref, 'refs/tags/') }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Publish from staging - run: "task artifacts:publish:${{ github.ref_name }}" - env: - AWS_ACCESS_KEY_ID: "${{ secrets.PUBLISHER_KEY_ID }}" - AWS_SECRET_ACCESS_KEY: "${{ secrets.PUBLISHER_KEY_SECRET }}" - AWS_DEFAULT_REGION: us-west-2 - shell: bash - publish-snap-amd64: - name: Publish AMD64 Snap - if: ${{ startsWith(github.ref, 'refs/tags/') }} - needs: [publish-s3] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Install Snapcraft - run: sudo snap install snapcraft --classic - shell: bash - - name: Download Snap from Release - uses: robinraju/release-downloader@v1 - with: - tag: ${{github.ref_name}} - fileName: "*amd64.snap" - - name: Publish to Snapcraft - run: "task artifacts:snap:publish:${{ github.ref_name }}" - env: - SNAPCRAFT_STORE_CREDENTIALS: "${{secrets.SNAPCRAFT_LOGIN_CREDS}}" - shell: bash - publish-snap-arm64: - name: Publish ARM64 Snap - if: ${{ startsWith(github.ref, 'refs/tags/') }} - needs: [publish-s3] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Install Snapcraft - run: sudo snap install snapcraft --classic - shell: bash - - name: Download Snap from Release - uses: robinraju/release-downloader@v1 - with: - tag: ${{github.ref_name}} - fileName: "*arm64.snap" - - name: Publish to Snapcraft - run: "task artifacts:snap:publish:${{ github.ref_name }}" - env: - SNAPCRAFT_STORE_CREDENTIALS: "${{secrets.SNAPCRAFT_LOGIN_CREDS}}" - shell: bash - bump-winget: - name: Submit WinGet PR - if: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, 'beta') }} - needs: [publish-s3] - runs-on: windows-latest - steps: - - uses: actions/checkout@v6 - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Install wingetcreate - run: winget install -e --silent --accept-package-agreements --accept-source-agreements wingetcreate - shell: pwsh - - name: Submit WinGet version bump - run: "task artifacts:winget:publish:${{ github.ref_name }}" - env: - GITHUB_TOKEN: ${{ secrets.WINGET_BUMP_PAT }} - shell: pwsh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd58c5251e..8dafd581c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,8 +59,12 @@ jobs: with: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Build - run: task package + - name: Build backend + run: task build:backend + - name: Build tsunami scaffold + run: task build:tsunamiscaffold + - name: Build frontend and package + run: npm run build:prod && npm exec electron-builder -- -c electron-builder.config.cjs -p never env: USE_SYSTEM_FPM: true shell: bash From 0e484dd69aeedc87694f3c786f5535d8ba7c1de1 Mon Sep 17 00:00:00 2001 From: "r.shi" Date: Fri, 8 May 2026 18:44:38 +0800 Subject: [PATCH 5/8] fix: rewrite release workflow without task runner dependency Completely remove dependency on `task` CLI. Use direct go/npm commands for all build steps. Remove docs workspace before npm ci to prevent sharp/docsite issues. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 96 ++++++++++++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8dafd581c6..a1806e57fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,59 +15,139 @@ jobs: include: - platform: "darwin" runner: "macos-latest" + archs: "arm64,amd64" - platform: "linux" runner: "ubuntu-latest" + archs: "amd64" - platform: "windows" runner: "windows-latest" + archs: "amd64" runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 + - name: Install Linux Build Dependencies if: matrix.platform == 'linux' run: | sudo apt-get update sudo apt-get install --no-install-recommends -y libarchive-tools libopenjp2-tools rpm squashfs-tools + - name: Install Zig (not Mac) if: matrix.platform != 'darwin' uses: mlugg/setup-zig@v2 + - name: Install FPM (not Windows) if: matrix.platform != 'windows' run: sudo gem install fpm + - name: Install FPM (Windows only) if: matrix.platform == 'windows' run: gem install fpm + - uses: actions/setup-go@v5 with: go-version: ${{env.GO_VERSION}} cache-dependency-path: go.sum + - uses: actions/setup-node@v4 with: node-version: ${{env.NODE_VERSION}} cache: npm cache-dependency-path: package-lock.json + - name: Force git deps to HTTPS run: | git config --global url.https://github.com/.insteadof ssh://git@github.com/ git config --global url.https://github.com/.insteadof git@github.com: + + - name: Remove docs workspace before install + run: | + node -e "const p=require('./package.json'); p.workspaces=p.workspaces.filter(w=>w!=='docs'); require('fs').writeFileSync('package.json',JSON.stringify(p,null,4))" + shell: bash + - name: npm ci run: npm ci --no-audit --no-fund env: GIT_ASKPASS: "echo" GIT_TERMINAL_PROMPT: "0" - - name: Install Task - uses: arduino/setup-task@v2 - with: - version: 3.x - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Build backend - run: task build:backend + + - name: Get version + id: version + run: echo "VERSION=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Generate code + run: | + go run cmd/generatets/main-generatets.go + go run cmd/generatego/main-generatego.go + go run cmd/generateschema/main-generateschema.go + rm -rf dist/schema + mkdir -p dist/schema + cp schema/*.json dist/schema/ 2>/dev/null || true + shell: bash + + - name: Build wavesrv + run: | + VERSION="${{ steps.version.outputs.VERSION }}" + BUILD_TIME=$(date +'%Y%m%d%H%M') + IFS=',' read -ra ARCH_LIST <<< "${{ matrix.archs }}" + for GOARCH in "${ARCH_LIST[@]}"; do + NORMALIZED_ARCH=$GOARCH + if [ "$GOARCH" = "amd64" ]; then NORMALIZED_ARCH="x64"; fi + EXT="" + GO_ENV="" + if [ "${{ matrix.platform }}" = "windows" ]; then + EXT=".exe" + GO_ENV="CC=\"zig cc -target x86_64-windows-gnu\"" + elif [ "${{ matrix.platform }}" = "linux" ]; then + if [ "$GOARCH" = "amd64" ]; then + GO_ENV="CC=\"zig cc -target x86_64-linux-gnu.2.28\"" + else + GO_ENV="CC=\"zig cc -target aarch64-linux-gnu.2.28\"" + fi + fi + eval "CGO_ENABLED=1 GOARCH=$GOARCH $GO_ENV go build -tags 'osusergo,sqlite_omit_load_extension' -ldflags '-X main.BuildTime=$BUILD_TIME -X main.WaveVersion=$VERSION' -o dist/bin/wavesrv.${NORMALIZED_ARCH}${EXT} cmd/server/main-server.go" + done + shell: bash + + - name: Build wsh + run: | + VERSION="${{ steps.version.outputs.VERSION }}" + BUILD_TIME=$(date +'%Y%m%d%H%M') + declare -a TARGETS=( + "darwin:arm64" + "darwin:amd64" + "linux:arm64" + "linux:amd64" + "windows:amd64" + "windows:arm64" + ) + for TARGET in "${TARGETS[@]}"; do + GOOS="${TARGET%%:*}" + GOARCH="${TARGET##*:}" + NORMALIZED_ARCH=$GOARCH + if [ "$GOARCH" = "amd64" ]; then NORMALIZED_ARCH="x64"; fi + EXT="" + if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi + CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH go build -ldflags="-s -w -X main.BuildTime=$BUILD_TIME -X main.WaveVersion=$VERSION" -o "dist/bin/wsh-${VERSION}-${GOOS}.${NORMALIZED_ARCH}${EXT}" cmd/wsh/main-wsh.go + done + shell: bash + - name: Build tsunami scaffold - run: task build:tsunamiscaffold + run: | + cd tsunami/frontend && npm run build && cd ../.. + rm -rf dist/tsunamiscaffold + mkdir -p dist/tsunamiscaffold + cp -r tsunami/frontend/scaffold/* dist/tsunamiscaffold/ 2>/dev/null || cp -r tsunami/frontend/dist/* dist/tsunamiscaffold/ 2>/dev/null || true + cp tsunami/templates/empty-gomod.tmpl dist/tsunamiscaffold/go.mod 2>/dev/null || true + shell: bash + - name: Build frontend and package run: npm run build:prod && npm exec electron-builder -- -c electron-builder.config.cjs -p never env: USE_SYSTEM_FPM: true shell: bash + - name: Upload artifacts uses: actions/upload-artifact@v4 with: From c61ca591a436fc89a424987684c04e3d239eb1ce Mon Sep 17 00:00:00 2001 From: "r.shi" Date: Fri, 15 May 2026 21:43:29 +0800 Subject: [PATCH 6/8] feat: add three-section VTabBar with working queue and archive queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the left sidebar tab bar into three sections: - Working Queue (pinned tabs) — always visible, actively used tabs - Archive Queue (unpinned tabs) — collapsible section for inactive tabs - New Tab button — fixed at bottom Backend: added PinnedTabIds field to Workspace, PinTab/UnpinTab/ UpdateWorkspacePinnedTabIds RPC commands. Activating a tab auto-pins it; archiving the active tab auto-switches focus to another pinned tab. Frontend: rewritten VTabBar with dual scroll zones, drag-and-drop between sections, right-click context menu for archive/restore, and collapsible archived section header with count. Co-Authored-By: Claude Opus 4.6 --- frontend/app/store/wshclientapi.ts | 18 ++ frontend/app/tab/tab.tsx | 3 + frontend/app/tab/tabcontextmenu.ts | 21 +- frontend/app/tab/vtabbar.tsx | 372 ++++++++++++++++++------ frontend/app/tab/vtabbarenv.ts | 3 + frontend/types/gotypes.d.ts | 1 + package-lock.json | 451 ----------------------------- pkg/waveobj/wtype.go | 17 +- pkg/wcore/workspace.go | 65 ++++- pkg/wshrpc/wshclient/wshclient.go | 18 ++ pkg/wshrpc/wshrpctypes.go | 3 + pkg/wshrpc/wshserver/wshserver.go | 30 ++ 12 files changed, 442 insertions(+), 560 deletions(-) diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 16b98a2577..b2c0b41edc 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -660,6 +660,12 @@ export class RpcApiType { return client.wshRpcCall("path", data, opts); } + // command "pintab" [call] + PinTabCommand(client: WshClient, arg1: string, arg2: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "pintab", { args: [arg1, arg2] }, opts); + return client.wshRpcCall("pintab", { args: [arg1, arg2] }, opts); + } + // command "publishapp" [call] PublishAppCommand(client: WshClient, data: CommandPublishAppData, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "publishapp", data, opts); @@ -954,12 +960,24 @@ export class RpcApiType { return client.wshRpcCall("testmultiarg", { args: [arg1, arg2, arg3] }, opts); } + // command "unpintab" [call] + UnpinTabCommand(client: WshClient, arg1: string, arg2: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "unpintab", { args: [arg1, arg2] }, opts); + return client.wshRpcCall("unpintab", { args: [arg1, arg2] }, opts); + } + // command "updatetabname" [call] UpdateTabNameCommand(client: WshClient, arg1: string, arg2: string, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "updatetabname", { args: [arg1, arg2] }, opts); return client.wshRpcCall("updatetabname", { args: [arg1, arg2] }, opts); } + // command "updateworkspacepinnedtabids" [call] + UpdateWorkspacePinnedTabIdsCommand(client: WshClient, arg1: string, arg2: string[], opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "updateworkspacepinnedtabids", { args: [arg1, arg2] }, opts); + return client.wshRpcCall("updateworkspacepinnedtabids", { args: [arg1, arg2] }, opts); + } + // command "updateworkspacetabids" [call] UpdateWorkspaceTabIdsCommand(client: WshClient, arg1: string, arg2: string[], opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "updateworkspacetabids", { args: [arg1, arg2] }, opts); diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 4972a13daa..2cd8937967 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -23,9 +23,12 @@ export type TabEnv = WaveEnvSubset<{ SetConfigCommand: WaveEnv["rpc"]["SetConfigCommand"]; SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; UpdateTabNameCommand: WaveEnv["rpc"]["UpdateTabNameCommand"]; + PinTabCommand: WaveEnv["rpc"]["PinTabCommand"]; + UnpinTabCommand: WaveEnv["rpc"]["UnpinTabCommand"]; }; atoms: { fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; + workspace: WaveEnv["atoms"]["workspace"]; }; wos: WaveEnv["wos"]; getSettingsKeyAtom: WaveEnv["getSettingsKeyAtom"]; diff --git a/frontend/app/tab/tabcontextmenu.ts b/frontend/app/tab/tabcontextmenu.ts index bc87302d4c..ea8324ffb3 100644 --- a/frontend/app/tab/tabcontextmenu.ts +++ b/frontend/app/tab/tabcontextmenu.ts @@ -40,7 +40,8 @@ export function buildTabContextMenu( id: string, renameRef: React.RefObject<(() => void) | null>, onClose: (event: React.MouseEvent | null) => void, - env: TabEnv + env: TabEnv, + isPinned?: boolean ): ContextMenuItem[] { const menu: ContextMenuItem[] = []; menu.push( @@ -51,6 +52,24 @@ export function buildTabContextMenu( }, { type: "separator" } ); + + if (isPinned != null) { + const workspace = globalStore.get(env.atoms.workspace); + if (workspace != null) { + if (isPinned) { + menu.push({ + label: "Archive Tab", + click: () => fireAndForget(() => env.rpc.UnpinTabCommand(TabRpcClient, workspace.oid, id)), + }); + } else { + menu.push({ + label: "Move to Working Queue", + click: () => fireAndForget(() => env.rpc.PinTabCommand(TabRpcClient, workspace.oid, id)), + }); + } + menu.push({ type: "separator" }); + } + } const tabORef = makeORef("tab", id); const currentFlagColor = globalStore.get(getOrefMetaKeyAtom(tabORef, "tab:flagcolor")) ?? null; const flagSubmenu: ContextMenuItem[] = [ diff --git a/frontend/app/tab/vtabbar.tsx b/frontend/app/tab/vtabbar.tsx index e40bcfb374..34aae6d92b 100644 --- a/frontend/app/tab/vtabbar.tsx +++ b/frontend/app/tab/vtabbar.tsx @@ -11,7 +11,7 @@ import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { validateCssColor } from "@/util/color-validator"; import { cn, fireAndForget } from "@/util/util"; import { useAtomValue } from "jotai"; -import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { buildTabBarContextMenu, buildTabContextMenu } from "./tabcontextmenu"; import { UpdateStatusBanner } from "./updatebanner"; import { VTab, VTabItem } from "./vtab"; @@ -92,6 +92,7 @@ interface VTabWrapperProps { isReordering: boolean; hoverResetVersion: number; index: number; + isPinned: boolean; onSelect: () => void; onClose: () => void; onRename: (newName: string) => void; @@ -109,6 +110,7 @@ function VTabWrapper({ isDragging, isReordering, hoverResetVersion, + isPinned, onSelect, onClose, onRename, @@ -156,10 +158,10 @@ function VTabWrapper({ (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - const menu = buildTabContextMenu(tabId, renameRef, () => onClose(), env); + const menu = buildTabContextMenu(tabId, renameRef, () => onClose(), env, isPinned); env.showContextMenu(menu, e); }, - [tabId, onClose, env] + [tabId, onClose, env, isPinned] ); return ( @@ -184,51 +186,106 @@ function VTabWrapper({ ); } +interface ArchivedSectionHeaderProps { + count: number; + expanded: boolean; + onToggle: () => void; +} + +function ArchivedSectionHeader({ count, expanded, onToggle }: ArchivedSectionHeaderProps) { + return ( + + ); +} + export function VTabBar({ workspace, className }: VTabBarProps) { const env = useWaveEnv(); const activeTabId = useAtomValue(env.atoms.staticTabId); const reinitVersion = useAtomValue(env.atoms.reinitVersion); const documentHasFocus = useAtomValue(env.atoms.documentHasFocus); const tabIds = workspace?.tabids ?? []; + const pinnedTabIds = workspace?.pinnedtabids ?? []; const [orderedTabIds, setOrderedTabIds] = useState(tabIds); + const [orderedPinnedTabIds, setOrderedPinnedTabIds] = useState(pinnedTabIds); const [dragTabId, setDragTabId] = useState(null); const [dropIndex, setDropIndex] = useState(null); const [dropLineTop, setDropLineTop] = useState(null); + const [dropZone, setDropZone] = useState<"pinned" | "archived" | null>(null); const [hoverResetVersion, setHoverResetVersion] = useState(0); const [hoveredTabId, setHoveredTabId] = useState(null); const [isNewTabHovered, setIsNewTabHovered] = useState(false); + const [archivedExpanded, setArchivedExpanded] = useState(false); const dragSourceRef = useRef(null); + const dragSourceZoneRef = useRef<"pinned" | "archived" | null>(null); const didResetHoverForDragRef = useRef(false); - const scrollContainerRef = useRef(null); + const pinnedScrollRef = useRef(null); + const archivedScrollRef = useRef(null); const scrollAnimFrameRef = useRef(null); const scrollDirectionRef = useRef(0); const scrollSpeedRef = useRef(0); + const activeScrollContainerRef = useRef(null); + + const pinnedSet = useMemo(() => new Set(orderedPinnedTabIds), [orderedPinnedTabIds]); + + const archivedTabIds = useMemo(() => { + return orderedTabIds.filter((id) => !pinnedSet.has(id)); + }, [orderedTabIds, pinnedSet]); useEffect(() => { setOrderedTabIds(tabIds); }, [workspace?.tabids]); + useEffect(() => { + setOrderedPinnedTabIds(pinnedTabIds); + }, [workspace?.pinnedtabids]); + useEffect(() => { if (reinitVersion > 0) { setOrderedTabIds(workspace?.tabids ?? []); + setOrderedPinnedTabIds(workspace?.pinnedtabids ?? []); } }, [reinitVersion]); useEffect(() => { - if (activeTabId == null || scrollContainerRef.current == null) { + if (activeTabId == null) return; + const pinnedEl = pinnedScrollRef.current?.querySelector(`[data-tabid="${activeTabId}"]`); + if (pinnedEl) { + pinnedEl.scrollIntoView({ block: "nearest" }); return; } - const el = scrollContainerRef.current.querySelector(`[data-tabid="${activeTabId}"]`); - el?.scrollIntoView({ block: "nearest" }); + const archivedEl = archivedScrollRef.current?.querySelector(`[data-tabid="${activeTabId}"]`); + if (archivedEl) { + setArchivedExpanded(true); + archivedEl.scrollIntoView({ block: "nearest" }); + } }, [activeTabId]); useEffect(() => { - if (!documentHasFocus || activeTabId == null || scrollContainerRef.current == null) { + if (!documentHasFocus || activeTabId == null) return; + const pinnedEl = pinnedScrollRef.current?.querySelector(`[data-tabid="${activeTabId}"]`); + if (pinnedEl) { + pinnedEl.scrollIntoView({ block: "nearest" }); return; } - const el = scrollContainerRef.current.querySelector(`[data-tabid="${activeTabId}"]`); - el?.scrollIntoView({ block: "nearest" }); + const archivedEl = archivedScrollRef.current?.querySelector(`[data-tabid="${activeTabId}"]`); + if (archivedEl) { + archivedEl.scrollIntoView({ block: "nearest" }); + } }, [documentHasFocus]); const stopScrollLoop = useCallback(() => { @@ -240,11 +297,9 @@ export function VTabBar({ workspace, className }: VTabBarProps) { }, []); const startScrollLoop = useCallback(() => { - if (scrollAnimFrameRef.current != null) { - return; - } + if (scrollAnimFrameRef.current != null) return; const loop = () => { - const container = scrollContainerRef.current; + const container = activeScrollContainerRef.current; if (container == null || scrollDirectionRef.current === 0) { scrollAnimFrameRef.current = null; return; @@ -256,11 +311,9 @@ export function VTabBar({ workspace, className }: VTabBarProps) { }, []); const updateScrollFromDragY = useCallback( - (clientY: number) => { - const container = scrollContainerRef.current; - if (container == null) { - return; - } + (clientY: number, container: HTMLDivElement | null) => { + if (container == null) return; + activeScrollContainerRef.current = container; const EdgeZone = 60; const MaxScrollSpeed = 12; const rect = container.getBoundingClientRect(); @@ -289,32 +342,80 @@ export function VTabBar({ workspace, className }: VTabBarProps) { setHoverResetVersion((version) => version + 1); } dragSourceRef.current = null; + dragSourceZoneRef.current = null; setDragTabId(null); setDropIndex(null); setDropLineTop(null); + setDropZone(null); + activeScrollContainerRef.current = null; }; - const reorder = (targetIndex: number) => { + const reorderPinned = (targetIndex: number) => { const sourceTabId = dragSourceRef.current; - if (sourceTabId == null) { - return; - } - const sourceIndex = orderedTabIds.findIndex((id) => id === sourceTabId); - if (sourceIndex === -1) { + if (sourceTabId == null) return; + + const wasPinned = pinnedSet.has(sourceTabId); + + if (!wasPinned) { + const nextPinned = [...orderedPinnedTabIds]; + const bounded = Math.max(0, Math.min(targetIndex, nextPinned.length)); + nextPinned.splice(bounded, 0, sourceTabId); + setOrderedPinnedTabIds(nextPinned); + fireAndForget(() => env.rpc.UpdateWorkspacePinnedTabIdsCommand(TabRpcClient, workspace.oid, nextPinned)); return; } - const boundedTargetIndex = Math.max(0, Math.min(targetIndex, orderedTabIds.length)); + + const sourceIndex = orderedPinnedTabIds.findIndex((id) => id === sourceTabId); + if (sourceIndex === -1) return; + const boundedTargetIndex = Math.max(0, Math.min(targetIndex, orderedPinnedTabIds.length)); const adjustedTargetIndex = sourceIndex < boundedTargetIndex ? boundedTargetIndex - 1 : boundedTargetIndex; - if (sourceIndex === adjustedTargetIndex) { + if (sourceIndex === adjustedTargetIndex) return; + const nextPinned = [...orderedPinnedTabIds]; + const [movedId] = nextPinned.splice(sourceIndex, 1); + nextPinned.splice(adjustedTargetIndex, 0, movedId); + setOrderedPinnedTabIds(nextPinned); + fireAndForget(() => env.rpc.UpdateWorkspacePinnedTabIdsCommand(TabRpcClient, workspace.oid, nextPinned)); + }; + + const reorderArchived = (targetIndex: number) => { + const sourceTabId = dragSourceRef.current; + if (sourceTabId == null) return; + + const wasPinned = pinnedSet.has(sourceTabId); + + if (wasPinned) { + const nextPinned = orderedPinnedTabIds.filter((id) => id !== sourceTabId); + setOrderedPinnedTabIds(nextPinned); + fireAndForget(() => env.rpc.UpdateWorkspacePinnedTabIdsCommand(TabRpcClient, workspace.oid, nextPinned)); return; } - const nextTabIds = [...orderedTabIds]; - const [movedId] = nextTabIds.splice(sourceIndex, 1); - nextTabIds.splice(adjustedTargetIndex, 0, movedId); + + // reorder within archived is a reorder within the full tabIds, keeping pinned positions stable + const sourceGlobalIndex = orderedTabIds.findIndex((id) => id === sourceTabId); + if (sourceGlobalIndex === -1) return; + + const archivedIds = orderedTabIds.filter((id) => !pinnedSet.has(id)); + const sourceArchivedIndex = archivedIds.findIndex((id) => id === sourceTabId); + if (sourceArchivedIndex === -1) return; + const boundedTarget = Math.max(0, Math.min(targetIndex, archivedIds.length)); + const adjusted = sourceArchivedIndex < boundedTarget ? boundedTarget - 1 : boundedTarget; + if (sourceArchivedIndex === adjusted) return; + + const nextArchived = [...archivedIds]; + const [movedId] = nextArchived.splice(sourceArchivedIndex, 1); + nextArchived.splice(adjusted, 0, movedId); + + // rebuild full tabIds: pinned first in their order, then archived in new order + const nextTabIds = [...orderedPinnedTabIds, ...nextArchived]; setOrderedTabIds(nextTabIds); fireAndForget(() => env.rpc.UpdateWorkspaceTabIdsCommand(TabRpcClient, workspace.oid, nextTabIds)); }; + const handleSelectArchived = (tabId: string) => { + fireAndForget(() => env.rpc.PinTabCommand(TabRpcClient, workspace.oid, tabId)); + env.electron.setActiveTab(tabId); + }; + const handleTabBarContextMenu = useCallback( (e: React.MouseEvent) => { e.preventDefault(); @@ -324,6 +425,102 @@ export function VTabBar({ workspace, className }: VTabBarProps) { [env] ); + const makeDragStartHandler = (tabId: string, zone: "pinned" | "archived", index: number) => { + return (event: React.DragEvent) => { + didResetHoverForDragRef.current = false; + dragSourceRef.current = tabId; + dragSourceZoneRef.current = zone; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", tabId); + setDragTabId(tabId); + setDropIndex(index); + setDropZone(zone); + setDropLineTop(event.currentTarget.offsetTop); + }; + }; + + const makeDragOverHandler = (zone: "pinned" | "archived", index: number) => { + return (event: React.DragEvent) => { + event.preventDefault(); + const rect = event.currentTarget.getBoundingClientRect(); + const relativeY = event.clientY - rect.top; + const midpoint = event.currentTarget.offsetHeight / 2; + const insertBefore = relativeY < midpoint; + setDropIndex(insertBefore ? index : index + 1); + setDropZone(zone); + setDropLineTop( + insertBefore + ? event.currentTarget.offsetTop + : event.currentTarget.offsetTop + event.currentTarget.offsetHeight + ); + }; + }; + + const makeDropHandler = (zone: "pinned" | "archived") => { + return (event: React.DragEvent) => { + event.preventDefault(); + if (dropIndex != null) { + if (zone === "pinned") { + reorderPinned(dropIndex); + } else { + reorderArchived(dropIndex); + } + } + clearDragState(); + }; + }; + + const renderTabList = ( + ids: string[], + zone: "pinned" | "archived", + scrollRef: React.RefObject + ) => { + return ids.map((tabId, index) => { + const isActive = tabId === activeTabId; + const isHovered = tabId === hoveredTabId; + const isLast = index === ids.length - 1; + const nextTabId = ids[index + 1]; + const isNextActive = nextTabId === activeTabId; + const isNextHovered = nextTabId === hoveredTabId; + const isPinned = zone === "pinned"; + return ( + { + if (isPinned) { + env.electron.setActiveTab(tabId); + } else { + handleSelectArchived(tabId); + } + }} + onClose={() => fireAndForget(() => env.electron.closeTab(workspace.oid, tabId, false))} + onRename={(newName) => + fireAndForget(() => env.rpc.UpdateTabNameCommand(TabRpcClient, tabId, newName)) + } + onDragStart={makeDragStartHandler(tabId, zone, index)} + onDragOver={makeDragOverHandler(zone, index)} + onDrop={makeDropHandler(zone)} + onDragEnd={clearDragState} + onHoverChanged={(isHovered) => setHoveredTabId(isHovered ? tabId : null)} + /> + ); + }); + }; + return (
{env.isMacOS() && } + {/* Pinned / Working Queue */}
{ event.preventDefault(); - updateScrollFromDragY(event.clientY); + updateScrollFromDragY(event.clientY, pinnedScrollRef.current); if (event.target === event.currentTarget) { - setDropIndex(orderedTabIds.length); + setDropIndex(orderedPinnedTabIds.length); + setDropZone("pinned"); setDropLineTop(event.currentTarget.scrollHeight); } }} onDrop={(event) => { event.preventDefault(); if (dropIndex != null) { - reorder(dropIndex); + reorderPinned(dropIndex); } clearDragState(); }} > - {orderedTabIds.map((tabId, index) => { - const isActive = tabId === activeTabId; - const isHovered = tabId === hoveredTabId; - const isLast = index === orderedTabIds.length - 1; - const nextTabId = orderedTabIds[index + 1]; - const isNextActive = nextTabId === activeTabId; - const isNextHovered = nextTabId === hoveredTabId; - return ( - env.electron.setActiveTab(tabId)} - onClose={() => fireAndForget(() => env.electron.closeTab(workspace.oid, tabId, false))} - onRename={(newName) => - fireAndForget(() => env.rpc.UpdateTabNameCommand(TabRpcClient, tabId, newName)) - } - onDragStart={(event) => { - didResetHoverForDragRef.current = false; - dragSourceRef.current = tabId; - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("text/plain", tabId); - setDragTabId(tabId); - setDropIndex(index); - setDropLineTop(event.currentTarget.offsetTop); - }} + {renderTabList(orderedPinnedTabIds, "pinned", pinnedScrollRef)} + {dragTabId != null && dropZone === "pinned" && dropIndex != null && dropLineTop != null && ( +
+ )} +
+ {/* Archived / Archive Queue */} + {archivedTabIds.length > 0 && ( +
+ setArchivedExpanded((prev) => !prev)} + /> + {archivedExpanded && ( +
{ event.preventDefault(); - const rect = event.currentTarget.getBoundingClientRect(); - const relativeY = event.clientY - rect.top; - const midpoint = event.currentTarget.offsetHeight / 2; - const insertBefore = relativeY < midpoint; - setDropIndex(insertBefore ? index : index + 1); - setDropLineTop( - insertBefore - ? event.currentTarget.offsetTop - : event.currentTarget.offsetTop + event.currentTarget.offsetHeight - ); + updateScrollFromDragY(event.clientY, archivedScrollRef.current); + if (event.target === event.currentTarget) { + setDropIndex(archivedTabIds.length); + setDropZone("archived"); + setDropLineTop(event.currentTarget.scrollHeight); + } }} onDrop={(event) => { event.preventDefault(); if (dropIndex != null) { - reorder(dropIndex); + reorderArchived(dropIndex); } clearDragState(); }} - onDragEnd={clearDragState} - onHoverChanged={(isHovered) => setHoveredTabId(isHovered ? tabId : null)} - /> - ); - })} - {dragTabId != null && dropIndex != null && dropLineTop != null && ( -
- )} -
+ > + {renderTabList(archivedTabIds, "archived", archivedScrollRef)} + {dragTabId != null && + dropZone === "archived" && + dropIndex != null && + dropLineTop != null && ( +
+ )} +
+ )} +
+ )} + {/* New Tab Button */}