feat(terminal): add embedded PTY-backed terminal dock with per-tab sessions
Adds a built-in terminal dock that runs a real pseudo-terminal per repository tab. - Backend: new src-tauri/src/terminal.rs implements TerminalState and Tauri commands terminal_open, terminal_write, terminal_resize and terminal_close using the portable-pty crate. Terminal output is emitted via "terminal:data" and exits via "terminal:exit". Includes unit tests for UTF-8 chunk handling. Cargo.toml updated to depend on portable-pty and main.rs registers the state/commands. - Frontend: App.svelte/UI and app.css updated to show a resizable terminal dock, toggleable with Ctrl+` and persisted open/height state. TerminalPanel.svelte is lazy-loaded per repo tab (frontend provides session ids like repo:<path>). package.json adds @xterm/xterm and @xterm/addon-fit for the frontend terminal surface. - Docs: CHANGELOG.md notes the new embedded terminal feature. Behavior: each repository tab has its own PTY-backed shell started in the repo cwd; the user's shell (SHELL/COMSPEC) is used so prompts, aliases and interactive behavior work as in a normal terminal.
This commit is contained in:
@@ -14,6 +14,10 @@ The project uses calendar-style versions in the form `YYYY.M.PATCH`.
|
|||||||
selection again.
|
selection again.
|
||||||
- Issues can be assigned to people, and labels are managed directly from the
|
- Issues can be assigned to people, and labels are managed directly from the
|
||||||
Issue Center.
|
Issue Center.
|
||||||
|
- An embedded terminal in the repository view, docked above the status bar and
|
||||||
|
adjustable in height. It runs a real pseudo terminal with the user's own
|
||||||
|
shell, so colors, interactive prompts, tab completion, and Ctrl+C work as
|
||||||
|
usual. Every repository tab keeps its own session; Ctrl + ` toggles the dock.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
Generated
+17
-78
@@ -13,6 +13,8 @@
|
|||||||
"@tauri-apps/api": "^2.5.0",
|
"@tauri-apps/api": "^2.5.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"dompurify": "^3.4.15",
|
"dompurify": "^3.4.15",
|
||||||
"marked": "^18.0.12",
|
"marked": "^18.0.12",
|
||||||
"simple-icons": "^16.24.1",
|
"simple-icons": "^16.24.1",
|
||||||
@@ -582,9 +584,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -598,9 +597,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -614,9 +610,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -630,9 +623,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -646,9 +636,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -662,9 +649,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -678,9 +662,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -694,9 +675,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -710,9 +688,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -726,9 +701,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -742,9 +714,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -758,9 +727,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -774,9 +740,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1045,9 +1008,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1064,9 +1024,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1083,9 +1040,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1102,9 +1056,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1288,9 +1239,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1308,9 +1256,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1328,9 +1273,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1348,9 +1290,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1368,9 +1307,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1461,6 +1397,21 @@
|
|||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-fit": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/xterm": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"addons/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.17.0",
|
"version": "8.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||||
@@ -1846,9 +1797,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1869,9 +1817,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1892,9 +1837,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1915,9 +1857,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
"@tauri-apps/api": "^2.5.0",
|
"@tauri-apps/api": "^2.5.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"dompurify": "^3.4.15",
|
"dompurify": "^3.4.15",
|
||||||
"marked": "^18.0.12",
|
"marked": "^18.0.12",
|
||||||
"simple-icons": "^16.24.1",
|
"simple-icons": "^16.24.1",
|
||||||
|
|||||||
Generated
+105
-6
@@ -511,6 +511,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg_aliases"
|
name = "cfg_aliases"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -919,6 +925,12 @@ dependencies = [
|
|||||||
"tendril",
|
"tendril",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "downcast-rs"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dpi"
|
name = "dpi"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -981,7 +993,7 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
"toml 1.1.2+spec-1.1.0",
|
"toml 1.1.2+spec-1.1.0",
|
||||||
"vswhom",
|
"vswhom",
|
||||||
"winreg",
|
"winreg 0.55.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1099,6 +1111,17 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filedescriptor"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filetime"
|
name = "filetime"
|
||||||
version = "0.2.29"
|
version = "0.2.29"
|
||||||
@@ -1475,6 +1498,7 @@ dependencies = [
|
|||||||
"commit_ai",
|
"commit_ai",
|
||||||
"keyring",
|
"keyring",
|
||||||
"log",
|
"log",
|
||||||
|
"portable-pty",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2160,6 +2184,12 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lazy_static"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libappindicator"
|
name = "libappindicator"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2378,6 +2408,18 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases 0.1.1",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
@@ -2386,7 +2428,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.2.1",
|
||||||
"libc",
|
"libc",
|
||||||
"memoffset",
|
"memoffset",
|
||||||
]
|
]
|
||||||
@@ -2399,7 +2441,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.2.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3055,6 +3097,27 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-pty"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"downcast-rs",
|
||||||
|
"filedescriptor",
|
||||||
|
"lazy_static",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"nix 0.28.0",
|
||||||
|
"serial2",
|
||||||
|
"shared_library",
|
||||||
|
"shell-words",
|
||||||
|
"winapi",
|
||||||
|
"winreg 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -3163,7 +3226,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.2.1",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn-proto",
|
"quinn-proto",
|
||||||
"quinn-udp",
|
"quinn-udp",
|
||||||
@@ -3203,7 +3266,7 @@ version = "0.5.14"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.2.1",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2",
|
||||||
@@ -3901,6 +3964,17 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serial2"
|
||||||
|
version = "0.2.38"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serialize-to-javascript"
|
name = "serialize-to-javascript"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -3954,6 +4028,22 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shared_library"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shell-words"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -4027,7 +4117,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
|
checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg_aliases",
|
"cfg_aliases 0.2.1",
|
||||||
"core-graphics 0.24.0",
|
"core-graphics 0.24.0",
|
||||||
"foreign-types 0.5.0",
|
"foreign-types 0.5.0",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -5954,6 +6044,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winreg"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winreg"
|
name = "winreg"
|
||||||
version = "0.55.0"
|
version = "0.55.0"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ log = "0.4"
|
|||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
sysinfo = { version = "=0.38.3", default-features = false, features = ["system"] }
|
sysinfo = { version = "=0.38.3", default-features = false, features = ["system"] }
|
||||||
shlex = "2"
|
shlex = "2"
|
||||||
|
portable-pty = "0.9"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod external_tools;
|
|||||||
mod git;
|
mod git;
|
||||||
mod integrations;
|
mod integrations;
|
||||||
mod telemetry;
|
mod telemetry;
|
||||||
|
mod terminal;
|
||||||
|
|
||||||
use badge::set_sync_badge;
|
use badge::set_sync_badge;
|
||||||
use external_tools::{
|
use external_tools::{
|
||||||
@@ -46,6 +47,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
|
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
|
||||||
|
use terminal::{TerminalState, terminal_close, terminal_open, terminal_resize, terminal_write};
|
||||||
|
|
||||||
struct StartupRepository(Mutex<Option<String>>);
|
struct StartupRepository(Mutex<Option<String>>);
|
||||||
|
|
||||||
@@ -325,6 +327,7 @@ async fn main() {
|
|||||||
.manage(StartupRepository(Mutex::new(startup_repository)))
|
.manage(StartupRepository(Mutex::new(startup_repository)))
|
||||||
.manage(StartupClone(Mutex::new(startup_clone)))
|
.manage(StartupClone(Mutex::new(startup_clone)))
|
||||||
.manage(SearchCancellationState::default())
|
.manage(SearchCancellationState::default())
|
||||||
|
.manage(TerminalState::default())
|
||||||
.plugin(tauri_plugin_dialog::init());
|
.plugin(tauri_plugin_dialog::init());
|
||||||
|
|
||||||
// Linux installs are expected to come from the system package manager (see the
|
// Linux installs are expected to come from the system package manager (see the
|
||||||
@@ -335,6 +338,10 @@ async fn main() {
|
|||||||
|
|
||||||
builder
|
builder
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
terminal_open,
|
||||||
|
terminal_write,
|
||||||
|
terminal_resize,
|
||||||
|
terminal_close,
|
||||||
open_repository,
|
open_repository,
|
||||||
init_repository,
|
init_repository,
|
||||||
clone_repository,
|
clone_repository,
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
//! Embedded terminal: one PTY-backed shell session per repository tab.
|
||||||
|
//!
|
||||||
|
//! The frontend owns the session ids and talks to a real pseudo terminal, so
|
||||||
|
//! colors, interactive prompts, tab completion and Ctrl+C behave exactly like
|
||||||
|
//! they do in the system terminal. Everything the shell prints is forwarded as
|
||||||
|
//! UTF-8 chunks; everything the user types goes back through `terminal_write`.
|
||||||
|
|
||||||
|
use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::{AppHandle, Emitter, Manager, State};
|
||||||
|
|
||||||
|
const DATA_EVENT: &str = "terminal:data";
|
||||||
|
const EXIT_EVENT: &str = "terminal:exit";
|
||||||
|
|
||||||
|
/// A read never returns more than this, so one busy command cannot starve the
|
||||||
|
/// event loop with a single enormous payload.
|
||||||
|
const READ_CHUNK: usize = 8 * 1024;
|
||||||
|
|
||||||
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct TerminalChunk {
|
||||||
|
id: String,
|
||||||
|
data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct TerminalExit {
|
||||||
|
id: String,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Session {
|
||||||
|
master: Box<dyn MasterPty + Send>,
|
||||||
|
writer: Box<dyn Write + Send>,
|
||||||
|
child: Box<dyn Child + Send + Sync>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct TerminalState(Mutex<HashMap<String, Session>>);
|
||||||
|
|
||||||
|
fn lock_error(details: impl std::fmt::Display) -> String {
|
||||||
|
format!("Terminal state is unavailable: {details}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user's own shell, so their aliases, prompt and PATH are present. A PTY
|
||||||
|
/// makes the shell interactive on its own, so no login flag is needed — that
|
||||||
|
/// would only risk a profile changing the working directory.
|
||||||
|
fn shell_command() -> CommandBuilder {
|
||||||
|
#[cfg(windows)]
|
||||||
|
let mut command = {
|
||||||
|
let shell = std::env::var("COMSPEC").unwrap_or_else(|_| "powershell.exe".to_string());
|
||||||
|
CommandBuilder::new(shell)
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
let mut command = {
|
||||||
|
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
|
||||||
|
CommandBuilder::new(shell)
|
||||||
|
};
|
||||||
|
|
||||||
|
// xterm.js speaks xterm-256color, and git should not page into a terminal
|
||||||
|
// the user cannot scroll back with a mouse wheel alone.
|
||||||
|
command.env("TERM", "xterm-256color");
|
||||||
|
command.env("COLORTERM", "truecolor");
|
||||||
|
command
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes as much of `buffer` as forms complete UTF-8 and returns it, leaving
|
||||||
|
/// a split multi-byte sequence in place for the next read.
|
||||||
|
fn take_utf8(buffer: &mut Vec<u8>) -> String {
|
||||||
|
match std::str::from_utf8(buffer) {
|
||||||
|
Ok(text) => {
|
||||||
|
let text = text.to_string();
|
||||||
|
buffer.clear();
|
||||||
|
text
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let valid = error.valid_up_to();
|
||||||
|
let text = String::from_utf8_lossy(&buffer[..valid]).into_owned();
|
||||||
|
match error.error_len() {
|
||||||
|
// Genuinely invalid bytes: drop them, or the stream would stall.
|
||||||
|
Some(length) => buffer.drain(..valid + length),
|
||||||
|
// An incomplete tail: keep it for the next chunk.
|
||||||
|
None => buffer.drain(..valid),
|
||||||
|
};
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn terminal_open(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, TerminalState>,
|
||||||
|
id: String,
|
||||||
|
cwd: String,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut sessions = state.0.lock().map_err(lock_error)?;
|
||||||
|
if sessions.contains_key(&id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let directory = Path::new(&cwd);
|
||||||
|
if !directory.is_dir() {
|
||||||
|
return Err(format!("Working directory does not exist: {cwd}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let size = PtySize {
|
||||||
|
rows: rows.max(1),
|
||||||
|
cols: cols.max(1),
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
};
|
||||||
|
let pair = native_pty_system()
|
||||||
|
.openpty(size)
|
||||||
|
.map_err(|error| format!("Could not open a pseudo terminal: {error}"))?;
|
||||||
|
|
||||||
|
let mut command = shell_command();
|
||||||
|
command.cwd(directory);
|
||||||
|
let child = pair
|
||||||
|
.slave
|
||||||
|
.spawn_command(command)
|
||||||
|
.map_err(|error| format!("Could not start the shell: {error}"))?;
|
||||||
|
// The slave side must go, otherwise the reader never sees end of file.
|
||||||
|
drop(pair.slave);
|
||||||
|
|
||||||
|
let mut reader = pair
|
||||||
|
.master
|
||||||
|
.try_clone_reader()
|
||||||
|
.map_err(|error| format!("Could not read from the terminal: {error}"))?;
|
||||||
|
let writer = pair
|
||||||
|
.master
|
||||||
|
.take_writer()
|
||||||
|
.map_err(|error| format!("Could not write to the terminal: {error}"))?;
|
||||||
|
|
||||||
|
let reader_app = app.clone();
|
||||||
|
let reader_id = id.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut pending: Vec<u8> = Vec::new();
|
||||||
|
let mut chunk = [0u8; READ_CHUNK];
|
||||||
|
let message = loop {
|
||||||
|
match reader.read(&mut chunk) {
|
||||||
|
Ok(0) => break String::new(),
|
||||||
|
Ok(count) => {
|
||||||
|
pending.extend_from_slice(&chunk[..count]);
|
||||||
|
let text = take_utf8(&mut pending);
|
||||||
|
if text.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if reader_app
|
||||||
|
.emit(
|
||||||
|
DATA_EVENT,
|
||||||
|
TerminalChunk {
|
||||||
|
id: reader_id.clone(),
|
||||||
|
data: text,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
// The window is gone; nothing left to deliver output to.
|
||||||
|
break String::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => break error.to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(state) = reader_app.try_state::<TerminalState>() {
|
||||||
|
if let Ok(mut sessions) = state.0.lock() {
|
||||||
|
if let Some(mut session) = sessions.remove(&reader_id) {
|
||||||
|
let _ = session.child.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = reader_app.emit(
|
||||||
|
EXIT_EVENT,
|
||||||
|
TerminalExit {
|
||||||
|
id: reader_id,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
sessions.insert(
|
||||||
|
id,
|
||||||
|
Session {
|
||||||
|
master: pair.master,
|
||||||
|
writer,
|
||||||
|
child,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn terminal_write(
|
||||||
|
state: State<'_, TerminalState>,
|
||||||
|
id: String,
|
||||||
|
data: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut sessions = state.0.lock().map_err(lock_error)?;
|
||||||
|
let session = sessions
|
||||||
|
.get_mut(&id)
|
||||||
|
.ok_or_else(|| "This terminal session is no longer running.".to_string())?;
|
||||||
|
session
|
||||||
|
.writer
|
||||||
|
.write_all(data.as_bytes())
|
||||||
|
.map_err(|error| format!("Could not send input to the terminal: {error}"))?;
|
||||||
|
session
|
||||||
|
.writer
|
||||||
|
.flush()
|
||||||
|
.map_err(|error| format!("Could not send input to the terminal: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn terminal_resize(
|
||||||
|
state: State<'_, TerminalState>,
|
||||||
|
id: String,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let sessions = state.0.lock().map_err(lock_error)?;
|
||||||
|
// A closed session is not an error here: resize races with tab switching.
|
||||||
|
let Some(session) = sessions.get(&id) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
session
|
||||||
|
.master
|
||||||
|
.resize(PtySize {
|
||||||
|
rows: rows.max(1),
|
||||||
|
cols: cols.max(1),
|
||||||
|
pixel_width: 0,
|
||||||
|
pixel_height: 0,
|
||||||
|
})
|
||||||
|
.map_err(|error| format!("Could not resize the terminal: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn terminal_close(state: State<'_, TerminalState>, id: String) -> Result<(), String> {
|
||||||
|
let mut sessions = state.0.lock().map_err(lock_error)?;
|
||||||
|
if let Some(mut session) = sessions.remove(&id) {
|
||||||
|
let _ = session.child.kill();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::take_utf8;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn take_utf8_keeps_a_split_sequence_for_the_next_chunk() {
|
||||||
|
// "ä" is two bytes; the second one arrives later.
|
||||||
|
let mut buffer = vec![b'a', 0xc3];
|
||||||
|
assert_eq!(take_utf8(&mut buffer), "a");
|
||||||
|
assert_eq!(buffer, vec![0xc3]);
|
||||||
|
|
||||||
|
buffer.push(0xa4);
|
||||||
|
assert_eq!(take_utf8(&mut buffer), "ä");
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn take_utf8_drops_invalid_bytes_instead_of_stalling() {
|
||||||
|
let mut buffer = vec![b'a', 0xff, b'b'];
|
||||||
|
assert_eq!(take_utf8(&mut buffer), "a");
|
||||||
|
assert_eq!(take_utf8(&mut buffer), "b");
|
||||||
|
assert!(buffer.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
-1
@@ -5,7 +5,7 @@
|
|||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
||||||
import { AlertCircle, Cherry, CloudOff, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
|
import { AlertCircle, Cherry, CloudOff, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, SquareTerminal, Star, X } from "@lucide/svelte";
|
||||||
import { beginFrontendShutdown, resumeFrontend } from "./lib/telemetry";
|
import { beginFrontendShutdown, resumeFrontend } from "./lib/telemetry";
|
||||||
import { setLanguage, t } from "./lib/i18n.svelte";
|
import { setLanguage, t } from "./lib/i18n.svelte";
|
||||||
import type { ConfirmRequest } from "./lib/components/ConfirmDialog.svelte";
|
import type { ConfirmRequest } from "./lib/components/ConfirmDialog.svelte";
|
||||||
@@ -298,6 +298,10 @@
|
|||||||
const AUTO_REFRESH_ENABLED_KEY = "gitlite.autoRefreshEnabled.v1";
|
const AUTO_REFRESH_ENABLED_KEY = "gitlite.autoRefreshEnabled.v1";
|
||||||
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
|
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
|
||||||
const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1";
|
const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1";
|
||||||
|
const TERMINAL_HEIGHT_KEY = "gitlite.terminalHeight.v1";
|
||||||
|
const TERMINAL_OPEN_KEY = "gitlite.terminalOpen.v1";
|
||||||
|
const TERMINAL_MIN_HEIGHT = 120;
|
||||||
|
const TERMINAL_MAX_HEIGHT = 720;
|
||||||
const SIDEBAR_VISIBILITY_KEY = "gitlite.sidebarVisibility.v1";
|
const SIDEBAR_VISIBILITY_KEY = "gitlite.sidebarVisibility.v1";
|
||||||
const SIDEBAR_PANEL_HEIGHTS_KEY = "gitlite.sidebarPanelHeights.v2";
|
const SIDEBAR_PANEL_HEIGHTS_KEY = "gitlite.sidebarPanelHeights.v2";
|
||||||
const BRANCH_PANEL_COLLAPSED_KEY = "gitlite.branchPanelCollapsed.v1";
|
const BRANCH_PANEL_COLLAPSED_KEY = "gitlite.branchPanelCollapsed.v1";
|
||||||
@@ -566,6 +570,11 @@
|
|||||||
let resizeStartY = 0;
|
let resizeStartY = 0;
|
||||||
let resizeStartHeight = 0;
|
let resizeStartHeight = 0;
|
||||||
let leftSidebarWidth = loadLeftSidebarWidth();
|
let leftSidebarWidth = loadLeftSidebarWidth();
|
||||||
|
let terminalHeight = loadTerminalHeight();
|
||||||
|
let terminalOpen = loadTerminalOpen();
|
||||||
|
let resizingTerminal = false;
|
||||||
|
let terminalResizeStartY = 0;
|
||||||
|
let terminalResizeStartHeight = 0;
|
||||||
let resizingLeftSidebar = false;
|
let resizingLeftSidebar = false;
|
||||||
let leftSidebarResizeStartX = 0;
|
let leftSidebarResizeStartX = 0;
|
||||||
let leftSidebarResizeStartWidth = 0;
|
let leftSidebarResizeStartWidth = 0;
|
||||||
@@ -2041,6 +2050,60 @@
|
|||||||
persistCommitPanelHeight(commitPanelHeight);
|
persistCommitPanelHeight(commitPanelHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Embedded terminal ──────────────────────────────────────────────────────
|
||||||
|
function clampTerminalHeight(value: number): number {
|
||||||
|
return Math.min(TERMINAL_MAX_HEIGHT, Math.max(TERMINAL_MIN_HEIGHT, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTerminalHeight(): number {
|
||||||
|
try {
|
||||||
|
const stored = Number(localStorage.getItem(TERMINAL_HEIGHT_KEY));
|
||||||
|
return Number.isFinite(stored) && stored > 0 ? clampTerminalHeight(stored) : 260;
|
||||||
|
} catch { return 260; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistTerminalHeight(value: number) {
|
||||||
|
try { localStorage.setItem(TERMINAL_HEIGHT_KEY, String(value)); } catch { /* storage may be unavailable */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTerminalOpen(): boolean {
|
||||||
|
try { return localStorage.getItem(TERMINAL_OPEN_KEY) === "1"; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTerminal() {
|
||||||
|
terminalOpen = !terminalOpen;
|
||||||
|
try { localStorage.setItem(TERMINAL_OPEN_KEY, terminalOpen ? "1" : "0"); } catch { /* storage may be unavailable */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTerminalResize(event: PointerEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
resizingTerminal = true;
|
||||||
|
terminalResizeStartY = event.clientY;
|
||||||
|
terminalResizeStartHeight = terminalHeight;
|
||||||
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTerminalResizeMove(event: PointerEvent) {
|
||||||
|
if (!resizingTerminal) return;
|
||||||
|
// The dock grows upwards, so a smaller clientY means a taller terminal.
|
||||||
|
terminalHeight = clampTerminalHeight(terminalResizeStartHeight + (terminalResizeStartY - event.clientY));
|
||||||
|
}
|
||||||
|
|
||||||
|
function endTerminalResize(event: PointerEvent) {
|
||||||
|
if (!resizingTerminal) return;
|
||||||
|
resizingTerminal = false;
|
||||||
|
persistTerminalHeight(terminalHeight);
|
||||||
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTerminalResizeKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
||||||
|
event.preventDefault();
|
||||||
|
terminalHeight = clampTerminalHeight(terminalHeight + (event.key === "ArrowUp" ? 20 : -20));
|
||||||
|
persistTerminalHeight(terminalHeight);
|
||||||
|
}
|
||||||
|
|
||||||
function startLeftSidebarResize(event: PointerEvent) {
|
function startLeftSidebarResize(event: PointerEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
resizingLeftSidebar = true;
|
resizingLeftSidebar = true;
|
||||||
@@ -6012,6 +6075,13 @@
|
|||||||
openHelp();
|
openHelp();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Backquote by code, so the shortcut survives keyboard layouts where ` is
|
||||||
|
// a dead key.
|
||||||
|
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.code === "Backquote") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!event.repeat && workspaceActive) toggleTerminal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.key === "Escape" && commandPaletteOpen) {
|
if (event.key === "Escape" && commandPaletteOpen) {
|
||||||
commandPaletteOpen = false;
|
commandPaletteOpen = false;
|
||||||
return;
|
return;
|
||||||
@@ -6671,6 +6741,41 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
{#if terminalOpen}
|
||||||
|
<section class="terminal-dock" style="--terminal-height: {terminalHeight}px" aria-label={t("terminal.title")}>
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="terminal-resize-handle"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
aria-label={t("terminal.resize")}
|
||||||
|
aria-valuenow={terminalHeight}
|
||||||
|
aria-valuemin={TERMINAL_MIN_HEIGHT}
|
||||||
|
aria-valuemax={TERMINAL_MAX_HEIGHT}
|
||||||
|
tabindex="0"
|
||||||
|
onpointerdown={startTerminalResize}
|
||||||
|
onpointermove={onTerminalResizeMove}
|
||||||
|
onpointerup={endTerminalResize}
|
||||||
|
onpointercancel={endTerminalResize}
|
||||||
|
onkeydown={onTerminalResizeKeydown}
|
||||||
|
></div>
|
||||||
|
<header class="terminal-dock-head">
|
||||||
|
<span class="terminal-dock-title"><SquareTerminal size={13} aria-hidden="true" />{t("terminal.title")}</span>
|
||||||
|
<span class="terminal-dock-path" title={activeRepoPath}>{activeRepoPath}</span>
|
||||||
|
<button class="terminal-dock-close" type="button" onclick={toggleTerminal} title={t("terminal.hide")} aria-label={t("terminal.hide")}>
|
||||||
|
<X size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="terminal-dock-body">
|
||||||
|
{#await import("./lib/components/TerminalPanel.svelte") then module}
|
||||||
|
{#each repoTabs as tab (tab.path)}
|
||||||
|
<module.default sessionId={`repo:${tab.path}`} repoPath={tab.path} active={sameRepoPath(tab.path, activeRepoPath)} />
|
||||||
|
{/each}
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
<footer class="workspace-statusbar" aria-label="Application status summary">
|
<footer class="workspace-statusbar" aria-label="Application status summary">
|
||||||
@@ -6690,6 +6795,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<span class:active={autoRefreshEnabled} class="workspace-auto">Auto <i aria-hidden="true"></i></span>
|
<span class:active={autoRefreshEnabled} class="workspace-auto">Auto <i aria-hidden="true"></i></span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if workspaceActive}
|
||||||
|
<button class:active={terminalOpen} class="workspace-terminal-toggle" type="button" onclick={toggleTerminal} aria-pressed={terminalOpen} title={terminalOpen ? t("terminal.hide") : t("terminal.show")}>
|
||||||
|
<SquareTerminal size={12} aria-hidden="true" />{t("terminal.title")}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if appVersion}<span class="app-version" title={`Gitty version ${appVersion}`}>Gitty v{appVersion}</span>{/if}
|
{#if appVersion}<span class="app-version" title={`Gitty version ${appVersion}`}>Gitty v{appVersion}</span>{/if}
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+137
@@ -6453,6 +6453,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
|||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
grid-template-columns: minmax(220px, var(--left-sidebar-width, 280px)) 7px minmax(360px, 1fr) 7px minmax(var(--history-aside-min-width, 420px), var(--history-aside-width, 620px));
|
grid-template-columns: minmax(220px, var(--left-sidebar-width, 280px)) 7px minmax(360px, 1fr) 7px minmax(var(--history-aside-min-width, 420px), var(--history-aside-width, 620px));
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: var(--color-border-subtle);
|
background: var(--color-border-subtle);
|
||||||
@@ -6460,6 +6461,11 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
|||||||
}
|
}
|
||||||
.left-sidebar { background: var(--color-surface); }
|
.left-sidebar { background: var(--color-surface); }
|
||||||
.main-panel { border: 0; border-radius: 0; background: var(--color-surface-solid); }
|
.main-panel { border: 0; border-radius: 0; background: var(--color-surface-solid); }
|
||||||
|
.left-sidebar,
|
||||||
|
.left-sidebar-resize-handle,
|
||||||
|
.history-resize-handle,
|
||||||
|
.history-aside { grid-row: 1 / -1; }
|
||||||
|
.main-panel { grid-row: 1; }
|
||||||
.history-aside { background: var(--color-border-subtle); row-gap: 0; }
|
.history-aside { background: var(--color-border-subtle); row-gap: 0; }
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
@@ -9301,3 +9307,134 @@ section > header.page-header.page-header {
|
|||||||
|
|
||||||
/* Tab strips keep their hidden scrollbars. */
|
/* Tab strips keep their hidden scrollbars. */
|
||||||
.repo-tabs-scroll { scrollbar-width: none; }
|
.repo-tabs-scroll { scrollbar-width: none; }
|
||||||
|
|
||||||
|
/* ── Embedded terminal ─────────────────────────────────────────────────────── */
|
||||||
|
.terminal-dock {
|
||||||
|
/* Center column only: the gutters beside it belong to the resize handles,
|
||||||
|
which now run the full height. */
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 2;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 5px auto minmax(0, 1fr);
|
||||||
|
height: var(--terminal-height, 260px);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface-solid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-resize-handle {
|
||||||
|
cursor: ns-resize;
|
||||||
|
background: var(--color-border-subtle);
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.terminal-resize-handle:hover,
|
||||||
|
.terminal-resize-handle:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-dock-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 8px 0 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
background: var(--color-surface-dim);
|
||||||
|
}
|
||||||
|
.terminal-dock-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.terminal-dock-path {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
direction: rtl;
|
||||||
|
text-align: left;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.terminal-dock-close {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.terminal-dock-close:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-danger) 16%, transparent);
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-dock-body {
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-surface {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
padding: 6px 4px 6px 10px;
|
||||||
|
}
|
||||||
|
.terminal-surface.hidden { display: none; }
|
||||||
|
.terminal-host { width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
/* xterm draws its own scrollbar; keep it in the app's visual language. */
|
||||||
|
.terminal-surface .xterm-viewport { background: transparent !important; }
|
||||||
|
.terminal-surface .xterm-viewport::-webkit-scrollbar { width: 9px; }
|
||||||
|
.terminal-surface .xterm-viewport::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-ink-muted) 34%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-error {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--color-danger) 16%, var(--color-surface-solid));
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-terminal-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.workspace-terminal-toggle:hover { background: color-mix(in srgb, var(--color-primary) 16%, transparent); }
|
||||||
|
.workspace-terminal-toggle.active { color: var(--color-primary); }
|
||||||
|
|
||||||
|
/* Stacked layout: one column, so nothing spans rows. */
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.terminal-dock { grid-column: 1; }
|
||||||
|
.left-sidebar,
|
||||||
|
.left-sidebar-resize-handle,
|
||||||
|
.history-resize-handle,
|
||||||
|
.history-aside { grid-row: auto; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -249,6 +249,7 @@
|
|||||||
commands: [
|
commands: [
|
||||||
{ command: "Ctrl + /", description: "Diese Hilfe öffnen" },
|
{ command: "Ctrl + /", description: "Diese Hilfe öffnen" },
|
||||||
{ command: "Ctrl + 1 … 4", description: "Zwischen Dashboard, Repositories, Pull Requests und Issues & Boards wechseln" },
|
{ command: "Ctrl + 1 … 4", description: "Zwischen Dashboard, Repositories, Pull Requests und Issues & Boards wechseln" },
|
||||||
|
{ command: "Ctrl + ^", description: "Terminal im Repository ein- und ausblenden" },
|
||||||
{ command: "Ctrl + A", description: "Alle Dateien in der aktiven Statusliste („Ungestaged“ oder „Gestaged“) auswählen" },
|
{ command: "Ctrl + A", description: "Alle Dateien in der aktiven Statusliste („Ungestaged“ oder „Gestaged“) auswählen" },
|
||||||
{ command: "Escape", description: "Aktuelles Overlay oder Dialogfenster schließen – in der Statusliste die aktuelle Auswahl aufheben" },
|
{ command: "Escape", description: "Aktuelles Overlay oder Dialogfenster schließen – in der Statusliste die aktuelle Auswahl aufheben" },
|
||||||
{ command: "Tab / Shift + Tab", description: "Zwischen Bedienelementen wechseln" },
|
{ command: "Tab / Shift + Tab", description: "Zwischen Bedienelementen wechseln" },
|
||||||
@@ -461,6 +462,7 @@
|
|||||||
commands: [
|
commands: [
|
||||||
{ command: "Ctrl + /", description: "Open this help center" },
|
{ command: "Ctrl + /", description: "Open this help center" },
|
||||||
{ command: "Ctrl + 1 … 4", description: "Switch between Dashboard, Repositories, Pull Requests and Issues & Boards" },
|
{ command: "Ctrl + 1 … 4", description: "Switch between Dashboard, Repositories, Pull Requests and Issues & Boards" },
|
||||||
|
{ command: "Ctrl + `", description: "Show or hide the terminal inside the repository view" },
|
||||||
{ command: "Ctrl + A", description: "Select every file in the focused status list (Unstaged or Staged)" },
|
{ command: "Ctrl + A", description: "Select every file in the focused status list (Unstaged or Staged)" },
|
||||||
{ command: "Escape", description: "Close the current overlay or dialog – in the status list, clear the current selection" },
|
{ command: "Escape", description: "Close the current overlay or dialog – in the status list, clear the current selection" },
|
||||||
{ command: "Tab / Shift + Tab", description: "Move between controls" },
|
{ command: "Tab / Shift + Tab", description: "Move between controls" },
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount, tick } from "svelte";
|
||||||
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import { Terminal } from "@xterm/xterm";
|
||||||
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
import { closeTerminal, openTerminal, resizeTerminal, writeTerminal } from "../git";
|
||||||
|
import { t } from "../i18n.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Stable per repository tab, so every tab keeps its own shell. */
|
||||||
|
sessionId: string;
|
||||||
|
repoPath: string;
|
||||||
|
/** Only the active tab's terminal is visible; the others stay alive. */
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { sessionId, repoPath, active = false }: Props = $props();
|
||||||
|
|
||||||
|
let host = $state<HTMLDivElement | null>(null);
|
||||||
|
let terminal: Terminal | null = null;
|
||||||
|
let fitAddon: FitAddon | null = null;
|
||||||
|
let observer: ResizeObserver | null = null;
|
||||||
|
let unlisteners: UnlistenFn[] = [];
|
||||||
|
let started = false;
|
||||||
|
let exited = $state(false);
|
||||||
|
let error = $state("");
|
||||||
|
|
||||||
|
/** xterm needs concrete colors, so the theme tokens are resolved once here. */
|
||||||
|
function readTheme() {
|
||||||
|
const styles = getComputedStyle(document.documentElement);
|
||||||
|
const read = (name: string, fallback: string) => styles.getPropertyValue(name).trim() || fallback;
|
||||||
|
const foreground = read("--color-ink", "#e6e9f0");
|
||||||
|
return {
|
||||||
|
background: read("--color-surface-solid", "#12141a"),
|
||||||
|
foreground,
|
||||||
|
cursor: read("--color-primary", "#4f8cff"),
|
||||||
|
cursorAccent: read("--color-surface-solid", "#12141a"),
|
||||||
|
selectionBackground: read("--color-selection", "rgba(79, 140, 255, 0.32)"),
|
||||||
|
red: read("--code-delete-strong", "#e86060"),
|
||||||
|
green: read("--code-add-strong", "#4eca76"),
|
||||||
|
brightRed: read("--code-delete-text", "#e86060"),
|
||||||
|
brightGreen: read("--code-add-text", "#5dd88a"),
|
||||||
|
blue: read("--color-primary", "#4f8cff"),
|
||||||
|
brightBlue: read("--color-accent", "#7aa2ff"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fit() {
|
||||||
|
if (!fitAddon || !terminal || !active) return;
|
||||||
|
try {
|
||||||
|
fitAddon.fit();
|
||||||
|
void resizeTerminal(sessionId, terminal.cols, terminal.rows).catch(() => {});
|
||||||
|
} catch {
|
||||||
|
// A hidden or zero-sized host throws; the next visible fit corrects it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
if (started || !host) return;
|
||||||
|
started = true;
|
||||||
|
|
||||||
|
terminal = new Terminal({
|
||||||
|
fontFamily: getComputedStyle(document.documentElement).getPropertyValue("--font-mono").trim() || "monospace",
|
||||||
|
fontSize: 12.5,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
cursorBlink: true,
|
||||||
|
scrollback: 5000,
|
||||||
|
allowProposedApi: true,
|
||||||
|
theme: readTheme(),
|
||||||
|
});
|
||||||
|
fitAddon = new FitAddon();
|
||||||
|
terminal.loadAddon(fitAddon);
|
||||||
|
terminal.open(host);
|
||||||
|
await tick();
|
||||||
|
try { fitAddon.fit(); } catch { /* see fit() */ }
|
||||||
|
|
||||||
|
terminal.onData((data) => {
|
||||||
|
if (exited) return;
|
||||||
|
void writeTerminal(sessionId, data).catch((cause) => { error = String(cause); });
|
||||||
|
});
|
||||||
|
terminal.onResize(({ cols, rows }) => {
|
||||||
|
if (exited) return;
|
||||||
|
void resizeTerminal(sessionId, cols, rows).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
unlisteners.push(await listen<{ id: string; data: string }>("terminal:data", (event) => {
|
||||||
|
if (event.payload.id !== sessionId) return;
|
||||||
|
terminal?.write(event.payload.data);
|
||||||
|
}));
|
||||||
|
unlisteners.push(await listen<{ id: string; message: string }>("terminal:exit", (event) => {
|
||||||
|
if (event.payload.id !== sessionId) return;
|
||||||
|
exited = true;
|
||||||
|
if (event.payload.message) error = event.payload.message;
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openTerminal(sessionId, repoPath, terminal.cols, terminal.rows);
|
||||||
|
terminal.focus();
|
||||||
|
} catch (cause) {
|
||||||
|
error = String(cause);
|
||||||
|
exited = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void start();
|
||||||
|
if (host) {
|
||||||
|
observer = new ResizeObserver(() => fit());
|
||||||
|
observer.observe(host);
|
||||||
|
}
|
||||||
|
return () => {};
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
observer?.disconnect();
|
||||||
|
for (const unlisten of unlisteners) unlisten();
|
||||||
|
unlisteners = [];
|
||||||
|
terminal?.dispose();
|
||||||
|
terminal = null;
|
||||||
|
void closeTerminal(sessionId).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Becoming visible again needs a fresh measurement: xterm cannot size itself
|
||||||
|
// while its host is display:none.
|
||||||
|
$effect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
void tick().then(() => {
|
||||||
|
fit();
|
||||||
|
terminal?.focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="terminal-surface" class:hidden={!active} aria-hidden={!active}>
|
||||||
|
<div bind:this={host} class="terminal-host"></div>
|
||||||
|
{#if error}
|
||||||
|
<p class="terminal-error" role="status">{error}</p>
|
||||||
|
{:else if exited}
|
||||||
|
<p class="terminal-error" role="status">{t("terminal.exited")}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -777,3 +777,22 @@ export function getIntegrationIssueLabels(provider: GitIntegrationProvider, base
|
|||||||
export function setIntegrationIssueLabels(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number, labels: import("./types").IntegrationLabel[], expected: string[] | null = null): Promise<import("./types").IntegrationLabel[]> {
|
export function setIntegrationIssueLabels(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number, labels: import("./types").IntegrationLabel[], expected: string[] | null = null): Promise<import("./types").IntegrationLabel[]> {
|
||||||
return invoke("set_integration_issue_labels", { provider, baseUrl, username, token, repository, number, labels, expected });
|
return invoke("set_integration_issue_labels", { provider, baseUrl, username, token, repository, number, labels, expected });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Embedded terminal ────────────────────────────────────────────────────────
|
||||||
|
// Session ids are owned by the frontend: one per repository tab.
|
||||||
|
|
||||||
|
export function openTerminal(id: string, cwd: string, cols: number, rows: number): Promise<void> {
|
||||||
|
return invoke<void>("terminal_open", { id, cwd, cols, rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeTerminal(id: string, data: string): Promise<void> {
|
||||||
|
return invoke<void>("terminal_write", { id, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resizeTerminal(id: string, cols: number, rows: number): Promise<void> {
|
||||||
|
return invoke<void>("terminal_resize", { id, cols, rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeTerminal(id: string): Promise<void> {
|
||||||
|
return invoke<void>("terminal_close", { id });
|
||||||
|
}
|
||||||
|
|||||||
@@ -185,6 +185,15 @@ export const messages = {
|
|||||||
"stashes.drop": { en: "Drop", de: "Löschen" },
|
"stashes.drop": { en: "Drop", de: "Löschen" },
|
||||||
|
|
||||||
// ── Status panel ───────────────────────────────────────────────────────────
|
// ── Status panel ───────────────────────────────────────────────────────────
|
||||||
|
// ── Embedded terminal ──────────────────────────────────────────────────────
|
||||||
|
"terminal.title": { en: "Terminal", de: "Terminal" },
|
||||||
|
"terminal.show": { en: "Show terminal", de: "Terminal anzeigen" },
|
||||||
|
"terminal.hide": { en: "Hide terminal", de: "Terminal ausblenden" },
|
||||||
|
"terminal.close": { en: "Close terminal", de: "Terminal schließen" },
|
||||||
|
"terminal.resize": { en: "Resize terminal", de: "Terminalhöhe ändern" },
|
||||||
|
"terminal.exited": { en: "The shell has ended. Close and reopen the terminal to start a new one.", de: "Die Shell wurde beendet. Terminal schließen und erneut öffnen startet eine neue." },
|
||||||
|
"terminal.hint": { en: "Runs in the repository directory", de: "Läuft im Repository-Verzeichnis" },
|
||||||
|
|
||||||
"status.panelLabel": { en: "Working tree status", de: "Status des Arbeitsverzeichnisses" },
|
"status.panelLabel": { en: "Working tree status", de: "Status des Arbeitsverzeichnisses" },
|
||||||
"status.eyebrow": { en: "Workspace", de: "Arbeitsbereich" },
|
"status.eyebrow": { en: "Workspace", de: "Arbeitsbereich" },
|
||||||
"status.title": { en: "Changes", de: "Änderungen" },
|
"status.title": { en: "Changes", de: "Änderungen" },
|
||||||
|
|||||||
Reference in New Issue
Block a user