diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6c85c86..fc637da 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,9 @@ name: Build on: push: - branches: [main] + branches: ["*"] + pull_request: + branches: ["*"] permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e804536..cdc4065 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,10 @@ jobs: args: '--bundles nsis' - platform: ubuntu-22.04 args: '' + - platform: macos-latest + args: '--target aarch64-apple-darwin' + - platform: macos-latest + args: '--target x86_64-apple-darwin' runs-on: ${{ matrix.platform }} diff --git a/README.md b/README.md index b34cfa7..e4cf433 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Download from [Releases](https://github.com/branchcode/branchcode/releases). | 5 | Custom model endpoints | ❌ | | 6 | File tree & project management | ❌ | | 7 | Settings & preferences | ❌ | -| 8 | SSH tunneling (remote server coding)| ❌ | +| 8 | SSH tunneling (remote server coding)| πŸ”¨ | | 9 | Cross-platform testing (Linux/macOS)| ⚠️ | ## Developing diff --git a/bun.lock b/bun.lock index 7c26f79..38dab10 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@chenglou/pretext": "^0.0.4", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-shell": "^2", "@tauri-apps/plugin-updater": "^2", "ghostty-web": "^0.4.0", @@ -260,6 +261,8 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="], + "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.0", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw=="], + "@tauri-apps/plugin-shell": ["@tauri-apps/plugin-shell@2.3.5", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg=="], "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="], diff --git a/docs/ssh_integration/walkthrough.md b/docs/ssh_integration/walkthrough.md new file mode 100644 index 0000000..f80c6ca --- /dev/null +++ b/docs/ssh_integration/walkthrough.md @@ -0,0 +1,37 @@ +# SSH Remote Development Integration + +This document serves as a comprehensive walkthrough and post-mortem outlining the SSH Remote Access integration architecture within BranchCode. The implementation spans native pure-Rust backend capabilities synchronized seamlessly with React functional components on the frontend. + +## 1. Backend Infrastructure +Instead of relying on OS-level C/CMake build environments (like `libssh2`), we utilized pure-Rust implementations. +* **Dependencies**: `russh` v0.46, `russh-keys`, and `russh-sftp` were utilized for entirely cross-platform compilation capabilities void of native bindings. +* **Core Systems**: `ssh_client.rs` was instated. The main implementation is encapsulated within the singleton `SshManager` which governs generic SSH lifecycle tasks including configuration persistence, thread coordination, secure password and key-pair (public-key bindings using `Arc`) based authentication semantics, and explicit resource allocations. +* **Tauri Exposures**: The backend handles all states using global Tauri contexts (`SshState` mutexes) exposing 15 remote operations. +* **Caveats Resolved**: Alignment with `russh 0.46.0` trait constraints (handling string interpolations around `CryptoVec::from(data.to_vec())` to cast raw memory buffer shells respectively) was extensively managed. + +## 2. Frontend Connectivity +* **The useSsh Hook**: Actively maintains remote server states synced against the backend. It tracks instances across: `servers[]`, `connections[]`, and manages polling mechanisms to ensure UI parity on SSH drop-outs. +* **Data Typings**: TypeScript bindings (`tauri.ts`) seamlessly define shapes for `SshConnectionInfo` and `SshServerConfig` facilitating robust frontend rendering of state components. +* **GitPanel Sub-Layer**: The left `GitPanel` dock was retrofitted to house dynamic SSH components. It manages the custom "Add/Edit" authentication payload modal, handling custom credential passing efficiently (tracking both passphrase strings and active paths). + +## 3. Mixed File Tree Architecture +To allow transparent integration navigating both remote and local files, a mixed directory tree was orchestrated: +* **Separation of Concerns**: We constructed `useRemoteFileTree.ts` which uniquely pipes `sftp_list_dir` payload buffers parsed natively through Tauri. +* **User Interface**: `App.tsx` conditionally isolates files logically. Local resources retain standard representations, while all remote assets are uniquely tracked under distinct headers stamped visually utilizing **Globe** icons, reinforcing navigation locality visually off-hand. + +## 4. Left Sidebar Structural Revamp +To make way for extended project hierarchies: +* **The ProjectFolder Component**: We eliminated the flat mapping structure for conversations. The `ProjectFolder` allows hierarchical nesting under distinct umbrellas. +* Local workspace conversations naturally collapse under independent project folders, minimizing vertical bloat constraints. +* Remote assets scale seamlessly rendering distinct `ProjectFolder` entries marked heavily via teal accent server indicators, actively tracing heartbeat connectivity dots organically inline. + +## 5. Terminal Multiplexing Architecture +Binding remote shells into our global ecosystem was natively implemented: +* **Custom Event Emisisons**: Within `App.tsx`, invoking terminal requests on any active backend node safely binds and triggers decoupled custom `spawn-ssh-terminal` window broadcasts natively tracked contextually through `TerminalPanel`. +* **State Execution**: `useTerminal.ts` was fundamentally decoupled. It now binds natively against `type: 'ssh'` conditional parameters spawning `ssh_spawn_shell` backend abstractions. +* **Socket Emulation**: Rather than relying strictly over abstract PTY shells, PTY capabilities are artificially requested via native SFTP `xterm-256color` multiplex layers rendering completely via real-time WebSocket-like `ssh:data` and `ssh:exit` Tauri event emitters. +* **Distinguishing GUI Traits**: Remote tabs are explicitly prefixed uniformly with **`SSH: [Server Name]`** keeping workflows visually discrete from native local environments. + +## Future Recommendations +* **Reverse Port Forwarding**: Currently, OpenCode servers are localized. Port Forwarding logic bridging localized proxies towards standard OpenCode HTTP nodes remotely can seamlessly bridge remote agents logically. +* **Keep-Alives**: Consider instituting KeepAlive routines natively via custom Tauri background worker intervals maintaining arbitrary TTL connections passively preventing premature drop-outs natively alongside typical background SSH behavior. diff --git a/package.json b/package.json index 4d246b2..cf9456a 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-shell": "^2", "@tauri-apps/plugin-updater": "^2", + "@tauri-apps/plugin-dialog": "^2", "ghostty-web": "^0.4.0", "lucide-react": "^0.546.0", "motion": "^12.23.24", diff --git a/public/icons-ide/anyType.svg b/public/icons-ide/anyType.svg new file mode 100644 index 0000000..6ac5ba0 --- /dev/null +++ b/public/icons-ide/anyType.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/anyType_dark.svg b/public/icons-ide/anyType_dark.svg new file mode 100644 index 0000000..eee0e6b --- /dev/null +++ b/public/icons-ide/anyType_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/c.svg b/public/icons-ide/c.svg new file mode 100644 index 0000000..4c6b94d --- /dev/null +++ b/public/icons-ide/c.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons-ide/c_dark.svg b/public/icons-ide/c_dark.svg new file mode 100644 index 0000000..9aa7b36 --- /dev/null +++ b/public/icons-ide/c_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons-ide/cmake.svg b/public/icons-ide/cmake.svg new file mode 100644 index 0000000..1aeb0db --- /dev/null +++ b/public/icons-ide/cmake.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/icons-ide/config.svg b/public/icons-ide/config.svg new file mode 100644 index 0000000..8596966 --- /dev/null +++ b/public/icons-ide/config.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/icons-ide/config_dark.svg b/public/icons-ide/config_dark.svg new file mode 100644 index 0000000..7e652e5 --- /dev/null +++ b/public/icons-ide/config_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/icons-ide/cpp.svg b/public/icons-ide/cpp.svg new file mode 100644 index 0000000..254449f --- /dev/null +++ b/public/icons-ide/cpp.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/icons-ide/cpp_dark.svg b/public/icons-ide/cpp_dark.svg new file mode 100644 index 0000000..3c44423 --- /dev/null +++ b/public/icons-ide/cpp_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/icons-ide/csharp.svg b/public/icons-ide/csharp.svg new file mode 100644 index 0000000..6b36097 --- /dev/null +++ b/public/icons-ide/csharp.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons-ide/csharp_dark.svg b/public/icons-ide/csharp_dark.svg new file mode 100644 index 0000000..7fda33f --- /dev/null +++ b/public/icons-ide/csharp_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons-ide/css.svg b/public/icons-ide/css.svg new file mode 100644 index 0000000..6a39b72 --- /dev/null +++ b/public/icons-ide/css.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/icons-ide/dart.svg b/public/icons-ide/dart.svg new file mode 100644 index 0000000..5ce9cbc --- /dev/null +++ b/public/icons-ide/dart.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/icons-ide/dart_dark.svg b/public/icons-ide/dart_dark.svg new file mode 100644 index 0000000..2d0e595 --- /dev/null +++ b/public/icons-ide/dart_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/icons-ide/docker.svg b/public/icons-ide/docker.svg new file mode 100644 index 0000000..76c5f02 --- /dev/null +++ b/public/icons-ide/docker.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons-ide/docker_dark.svg b/public/icons-ide/docker_dark.svg new file mode 100644 index 0000000..f8a6d4c --- /dev/null +++ b/public/icons-ide/docker_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons-ide/elixir.svg b/public/icons-ide/elixir.svg new file mode 100644 index 0000000..be83e2a --- /dev/null +++ b/public/icons-ide/elixir.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons-ide/elixir_dark.svg b/public/icons-ide/elixir_dark.svg new file mode 100644 index 0000000..7c8ce9c --- /dev/null +++ b/public/icons-ide/elixir_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons-ide/erlang.svg b/public/icons-ide/erlang.svg new file mode 100644 index 0000000..37400de --- /dev/null +++ b/public/icons-ide/erlang.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons-ide/eslint.svg b/public/icons-ide/eslint.svg new file mode 100644 index 0000000..f8006e0 --- /dev/null +++ b/public/icons-ide/eslint.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/icons-ide/folder.svg b/public/icons-ide/folder.svg new file mode 100644 index 0000000..7ece6b7 --- /dev/null +++ b/public/icons-ide/folder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons-ide/folder_dark.svg b/public/icons-ide/folder_dark.svg new file mode 100644 index 0000000..0af492b --- /dev/null +++ b/public/icons-ide/folder_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons-ide/font.svg b/public/icons-ide/font.svg new file mode 100644 index 0000000..9e187b3 --- /dev/null +++ b/public/icons-ide/font.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/font_dark.svg b/public/icons-ide/font_dark.svg new file mode 100644 index 0000000..94fbd32 --- /dev/null +++ b/public/icons-ide/font_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/gitignore.svg b/public/icons-ide/gitignore.svg new file mode 100644 index 0000000..86228c6 --- /dev/null +++ b/public/icons-ide/gitignore.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons-ide/gleam.svg b/public/icons-ide/gleam.svg new file mode 100644 index 0000000..c74a794 --- /dev/null +++ b/public/icons-ide/gleam.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons-ide/gleam_dark.svg b/public/icons-ide/gleam_dark.svg new file mode 100644 index 0000000..5175ff6 --- /dev/null +++ b/public/icons-ide/gleam_dark.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/icons-ide/go.svg b/public/icons-ide/go.svg new file mode 100644 index 0000000..b2fab9c --- /dev/null +++ b/public/icons-ide/go.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/icons-ide/goSum.svg b/public/icons-ide/goSum.svg new file mode 100644 index 0000000..6b2c8f9 --- /dev/null +++ b/public/icons-ide/goSum.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons-ide/goSum_dark.svg b/public/icons-ide/goSum_dark.svg new file mode 100644 index 0000000..51acb9c --- /dev/null +++ b/public/icons-ide/goSum_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons-ide/goWork.svg b/public/icons-ide/goWork.svg new file mode 100644 index 0000000..39ea329 --- /dev/null +++ b/public/icons-ide/goWork.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons-ide/goWork_dark.svg b/public/icons-ide/goWork_dark.svg new file mode 100644 index 0000000..862dcec --- /dev/null +++ b/public/icons-ide/goWork_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons-ide/go_dark.svg b/public/icons-ide/go_dark.svg new file mode 100644 index 0000000..692e433 --- /dev/null +++ b/public/icons-ide/go_dark.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/icons-ide/gomod.svg b/public/icons-ide/gomod.svg new file mode 100644 index 0000000..d5b7829 --- /dev/null +++ b/public/icons-ide/gomod.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons-ide/gomod_dark.svg b/public/icons-ide/gomod_dark.svg new file mode 100644 index 0000000..06cbb21 --- /dev/null +++ b/public/icons-ide/gomod_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons-ide/graphql.svg b/public/icons-ide/graphql.svg new file mode 100644 index 0000000..88a0cd2 --- /dev/null +++ b/public/icons-ide/graphql.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/public/icons-ide/h.svg b/public/icons-ide/h.svg new file mode 100644 index 0000000..d5fafe8 --- /dev/null +++ b/public/icons-ide/h.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons-ide/h_dark.svg b/public/icons-ide/h_dark.svg new file mode 100644 index 0000000..27ca313 --- /dev/null +++ b/public/icons-ide/h_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons-ide/haskell.svg b/public/icons-ide/haskell.svg new file mode 100644 index 0000000..f8e5f05 --- /dev/null +++ b/public/icons-ide/haskell.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/hcl.svg b/public/icons-ide/hcl.svg new file mode 100644 index 0000000..0bebac1 --- /dev/null +++ b/public/icons-ide/hcl.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/hcl_dark.svg b/public/icons-ide/hcl_dark.svg new file mode 100644 index 0000000..b1740f1 --- /dev/null +++ b/public/icons-ide/hcl_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/image.svg b/public/icons-ide/image.svg new file mode 100644 index 0000000..9a09720 --- /dev/null +++ b/public/icons-ide/image.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/icons-ide/image_dark.svg b/public/icons-ide/image_dark.svg new file mode 100644 index 0000000..4f189a0 --- /dev/null +++ b/public/icons-ide/image_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/icons-ide/java.svg b/public/icons-ide/java.svg new file mode 100644 index 0000000..00e1a4c --- /dev/null +++ b/public/icons-ide/java.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/icons-ide/javaScript.svg b/public/icons-ide/javaScript.svg new file mode 100644 index 0000000..d8bff96 --- /dev/null +++ b/public/icons-ide/javaScript.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/icons-ide/javaScript_dark.svg b/public/icons-ide/javaScript_dark.svg new file mode 100644 index 0000000..043bb73 --- /dev/null +++ b/public/icons-ide/javaScript_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/icons-ide/java_dark.svg b/public/icons-ide/java_dark.svg new file mode 100644 index 0000000..155295f --- /dev/null +++ b/public/icons-ide/java_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/icons-ide/json.svg b/public/icons-ide/json.svg new file mode 100644 index 0000000..d8f707d --- /dev/null +++ b/public/icons-ide/json.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/json_dark.svg b/public/icons-ide/json_dark.svg new file mode 100644 index 0000000..29371ab --- /dev/null +++ b/public/icons-ide/json_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/kotlin.svg b/public/icons-ide/kotlin.svg new file mode 100644 index 0000000..c21c616 --- /dev/null +++ b/public/icons-ide/kotlin.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/kotlin_dark.svg b/public/icons-ide/kotlin_dark.svg new file mode 100644 index 0000000..10edacb --- /dev/null +++ b/public/icons-ide/kotlin_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/lua.svg b/public/icons-ide/lua.svg new file mode 100644 index 0000000..bf6536f --- /dev/null +++ b/public/icons-ide/lua.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons-ide/makefile.svg b/public/icons-ide/makefile.svg new file mode 100644 index 0000000..ad7f237 --- /dev/null +++ b/public/icons-ide/makefile.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons-ide/makefile_dark.svg b/public/icons-ide/makefile_dark.svg new file mode 100644 index 0000000..7c20195 --- /dev/null +++ b/public/icons-ide/makefile_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons-ide/php.svg b/public/icons-ide/php.svg new file mode 100644 index 0000000..2effc19 --- /dev/null +++ b/public/icons-ide/php.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/icons-ide/php_dark.svg b/public/icons-ide/php_dark.svg new file mode 100644 index 0000000..a96a947 --- /dev/null +++ b/public/icons-ide/php_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/icons-ide/python.svg b/public/icons-ide/python.svg new file mode 100644 index 0000000..99dca7b --- /dev/null +++ b/public/icons-ide/python.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/icons-ide/ruby.svg b/public/icons-ide/ruby.svg new file mode 100644 index 0000000..a92c07f --- /dev/null +++ b/public/icons-ide/ruby.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons-ide/ruby_dark.svg b/public/icons-ide/ruby_dark.svg new file mode 100644 index 0000000..4f27fc9 --- /dev/null +++ b/public/icons-ide/ruby_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/rustFile.svg b/public/icons-ide/rustFile.svg new file mode 100644 index 0000000..99275c7 --- /dev/null +++ b/public/icons-ide/rustFile.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons-ide/rustFile_dark.svg b/public/icons-ide/rustFile_dark.svg new file mode 100644 index 0000000..86a824f --- /dev/null +++ b/public/icons-ide/rustFile_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons-ide/scala.svg b/public/icons-ide/scala.svg new file mode 100644 index 0000000..6b6af3f --- /dev/null +++ b/public/icons-ide/scala.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/icons-ide/scala_dark.svg b/public/icons-ide/scala_dark.svg new file mode 100644 index 0000000..3fd07cc --- /dev/null +++ b/public/icons-ide/scala_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/icons-ide/shell.svg b/public/icons-ide/shell.svg new file mode 100644 index 0000000..0676afd --- /dev/null +++ b/public/icons-ide/shell.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/icons-ide/shell_dark.svg b/public/icons-ide/shell_dark.svg new file mode 100644 index 0000000..7022d0c --- /dev/null +++ b/public/icons-ide/shell_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/icons-ide/swift.svg b/public/icons-ide/swift.svg new file mode 100644 index 0000000..1fdf0a4 --- /dev/null +++ b/public/icons-ide/swift.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons-ide/terraform.svg b/public/icons-ide/terraform.svg new file mode 100644 index 0000000..13108b6 --- /dev/null +++ b/public/icons-ide/terraform.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/icons-ide/terraform_dark.svg b/public/icons-ide/terraform_dark.svg new file mode 100644 index 0000000..c953fbb --- /dev/null +++ b/public/icons-ide/terraform_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/icons-ide/text.svg b/public/icons-ide/text.svg new file mode 100644 index 0000000..110139c --- /dev/null +++ b/public/icons-ide/text.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/icons-ide/text_dark.svg b/public/icons-ide/text_dark.svg new file mode 100644 index 0000000..8646827 --- /dev/null +++ b/public/icons-ide/text_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/icons-ide/toml.svg b/public/icons-ide/toml.svg new file mode 100644 index 0000000..10789f0 --- /dev/null +++ b/public/icons-ide/toml.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/toml_dark.svg b/public/icons-ide/toml_dark.svg new file mode 100644 index 0000000..b9e6aea --- /dev/null +++ b/public/icons-ide/toml_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/tsx.svg b/public/icons-ide/tsx.svg new file mode 100644 index 0000000..61572ae --- /dev/null +++ b/public/icons-ide/tsx.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/tsx_dark.svg b/public/icons-ide/tsx_dark.svg new file mode 100644 index 0000000..1689eea --- /dev/null +++ b/public/icons-ide/tsx_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/typeScript.svg b/public/icons-ide/typeScript.svg new file mode 100644 index 0000000..957790f --- /dev/null +++ b/public/icons-ide/typeScript.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/typeScript_dark.svg b/public/icons-ide/typeScript_dark.svg new file mode 100644 index 0000000..bcac440 --- /dev/null +++ b/public/icons-ide/typeScript_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/vueJs.svg b/public/icons-ide/vueJs.svg new file mode 100644 index 0000000..fb1f7dd --- /dev/null +++ b/public/icons-ide/vueJs.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/zig.svg b/public/icons-ide/zig.svg new file mode 100644 index 0000000..2e3da0f --- /dev/null +++ b/public/icons-ide/zig.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons-ide/zig_dark.svg b/public/icons-ide/zig_dark.svg new file mode 100644 index 0000000..addf988 --- /dev/null +++ b/public/icons-ide/zig_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 86eca5d..0b03e87 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,54 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -222,6 +270,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -234,6 +288,23 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -273,6 +344,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -295,26 +375,42 @@ dependencies = [ "piper", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "branchcode" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "base64 0.22.1", "dashmap", "dirs", "futures-util", "portable-pty", "reqwest 0.12.28", + "russh", + "russh-keys", + "russh-sftp", "serde", "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-opener", "tauri-plugin-shell", "tauri-plugin-updater", "tokio", "urlencoding", + "uuid", ] [[package]] @@ -432,6 +528,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.58" @@ -475,6 +580,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "chrono" version = "0.4.44" @@ -482,11 +598,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "combine" version = "4.6.7" @@ -506,6 +634,32 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -605,6 +759,24 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -612,6 +784,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -665,6 +838,42 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.23.0" @@ -713,6 +922,34 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -768,6 +1005,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + [[package]] name = "digest" version = "0.10.7" @@ -775,7 +1021,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -902,6 +1150,66 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.8" @@ -1021,6 +1329,22 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -1069,6 +1393,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flurry" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf5efcf77a4da27927d3ab0509dec5b0954bb3bc59da5a1de9e52642ebd4cdf9" +dependencies = [ + "ahash", + "num_cpus", + "parking_lot", + "seize", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1148,6 +1484,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1155,6 +1506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1222,6 +1574,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1348,6 +1701,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1368,8 +1722,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1397,6 +1753,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gio" version = "0.18.4" @@ -1493,6 +1859,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "gtk" version = "0.18.2" @@ -1616,22 +1993,55 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "html5ever" -version = "0.29.1" +name = "hex-literal" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever 0.14.1", - "match_token", -] +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" [[package]] -name = "html5ever" -version = "0.38.0" +name = "hkdf" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever 0.14.1", + "match_token", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ "log", "markup5ever 0.38.0", @@ -1935,6 +2345,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ioctl-rs" version = "0.1.6" @@ -2114,6 +2534,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -2161,6 +2584,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.15" @@ -2248,6 +2677,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" @@ -2399,12 +2834,59 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2412,6 +2894,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", ] [[package]] @@ -2578,6 +3071,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.3" @@ -2674,6 +3173,59 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + +[[package]] +name = "pageant" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "032d6201d2fb765158455ae0d5a510c016bb6da7232e5040e39e9c8db12b0afc" +dependencies = [ + "bytes", + "delegate", + "futures", + "rand 0.8.5", + "thiserror 1.0.69", + "tokio", + "windows 0.58.0", +] + [[package]] name = "pango" version = "0.18.3" @@ -2734,6 +3286,25 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2950,6 +3521,44 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der", + "pbkdf2", + "scrypt", + "sha2", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "pkcs5", + "rand_core 0.6.4", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -3002,6 +3611,29 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-pty" version = "0.8.1" @@ -3063,6 +3695,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3347,79 +3988,272 @@ dependencies = [ "hyper-util", "js-sys", "log", - "mime", - "native-tls", - "percent-encoding", - "pin-project-lite", - "rustls-pki-types", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "russh" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c536b90d8e2468d8dedc8de2369383c101325e23fffa3a30de713032862a11d4" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bitflags 2.11.0", + "byteorder", + "cbc", + "chacha20", + "ctr", + "curve25519-dalek", + "des", + "digest", + "elliptic-curve", + "flate2", + "futures", + "generic-array", + "hex-literal", + "hmac", + "log", + "num-bigint", + "once_cell", + "p256", + "p384", + "p521", + "poly1305", + "rand 0.8.5", + "rand_core 0.6.4", + "russh-cryptovec", + "russh-keys", + "russh-sftp", + "russh-util", + "sha1", + "sha2", + "ssh-encoding", + "ssh-key", + "subtle", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "russh-cryptovec" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fadd2c0ab350e21c66556f94ee06f766d8bdae3213857ba7610bfd8e10e51880" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "russh-keys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e3db166c8678c824627c2c46f619ed5ce4ae33f38a35403c62f6ab8f3985867" +dependencies = [ + "aes", + "async-trait", + "bcrypt-pbkdf", + "block-padding", + "byteorder", + "cbc", + "ctr", + "data-encoding", + "der", + "digest", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "futures", + "getrandom 0.2.17", + "hmac", + "home", + "inout", + "log", + "md5", + "num-integer", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2", + "pkcs1", + "pkcs5", + "pkcs8", + "rand 0.8.5", + "rand_core 0.6.4", + "rsa", + "russh-cryptovec", + "russh-util", + "sec1", "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", + "sha1", + "sha2", + "spki", + "ssh-encoding", + "ssh-key", + "thiserror 1.0.69", "tokio", - "tokio-native-tls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams 0.4.2", - "web-sys", + "tokio-stream", + "typenum", + "zeroize", ] [[package]] -name = "reqwest" -version = "0.13.2" +name = "russh-sftp" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "3bb94393cafad0530145b8f626d8687f1ee1dedb93d7ba7740d6ae81868b13b5" dependencies = [ - "base64 0.22.1", + "bitflags 2.11.0", "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", + "chrono", + "flurry", "log", - "percent-encoding", - "pin-project-lite", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", "serde", - "serde_json", - "sync_wrapper", + "thiserror 2.0.18", "tokio", - "tokio-rustls", "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams 0.5.0", - "web-sys", ] [[package]] -name = "ring" -version = "0.17.14" +name = "russh-util" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +checksum = "63aeb9d2b74f8f38befdc0c5172d5ffcf58f3d2ffcb423f3b6cdfe2c2d747b80" dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", ] [[package]] @@ -3535,6 +4369,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3610,6 +4453,31 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3633,6 +4501,12 @@ dependencies = [ "libc", ] +[[package]] +name = "seize" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "689224d06523904ebcc9b482c6a3f4f7fb396096645c4cd10c0d2ff7371a34d3" + [[package]] name = "selectors" version = "0.24.0" @@ -3901,6 +4775,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3976,6 +4861,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -4064,6 +4959,73 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "bcrypt-pbkdf", + "ed25519-dalek", + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4250,7 +5212,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -4332,7 +5294,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -4415,6 +5377,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" @@ -4433,7 +5437,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.18", "url", - "windows", + "windows 0.61.3", "zbus", ] @@ -4513,7 +5517,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -4538,7 +5542,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -4705,6 +5709,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -4763,6 +5776,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -5067,6 +6092,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -5413,10 +6448,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -5437,7 +6472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -5487,6 +6522,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -5509,14 +6554,27 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -5528,8 +6586,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -5546,6 +6604,17 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -5557,6 +6626,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -5601,6 +6681,15 @@ dependencies = [ "windows-strings 0.5.1", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5619,6 +6708,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -6064,7 +7163,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 06cc3e6..906f3fb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,6 +15,7 @@ tauri = { version = "2", features = [] } tauri-plugin-shell = "2" tauri-plugin-opener = "2" tauri-plugin-updater = "2" +tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", features = ["stream", "json"] } @@ -26,6 +27,11 @@ portable-pty = "0.8" anyhow = "1" dashmap = "6" base64 = "0.22" +russh = "0.46" +russh-keys = "0.46" +russh-sftp = "2.0" +uuid = { version = "1", features = ["v4"] } +async-trait = "0.1" [lib] name = "branchcode_lib" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 964d7b5..7ead8e4 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "permissions": [ "core:default", "opener:default", + "dialog:default", "shell:allow-spawn", "shell:allow-execute" ] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b9dec8d..7d4d82b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,22 +3,58 @@ mod server; mod git; mod pty; mod updater; +mod ssh_client; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use tokio::sync::Mutex as TokioMutex; use opencode_client::OcStreamEvent; use pty::PtyState; use serde::Serialize; -use std::sync::{Arc, Mutex}; -use tauri::ipc::Channel; -use tauri::State; +use ssh_client::SshState; +use tauri::{State, ipc::Channel}; struct AppState { client: Arc, - project_dir: String, - model: std::sync::Mutex, - git: std::sync::Mutex>, + project_dir: Mutex, + model: Mutex, + git: Mutex>, pty: PtyState, + ssh_manager: SshState, + server: TokioMutex>, + server_port: u16, + /// The OpenCode server doesn't persist workdir or ssh_config_id per session, so we track them ourselves. + session_workdirs: Mutex>, + session_ssh_configs: Mutex>, + session_meta_path: std::path::PathBuf, +} + +#[derive(serde::Serialize, serde::Deserialize, Default)] +struct SessionMeta { + workdirs: HashMap, + ssh_configs: HashMap, +} + +fn load_session_meta(path: &std::path::Path) -> SessionMeta { + if let Ok(contents) = std::fs::read_to_string(path) { + if let Ok(meta) = serde_json::from_str(&contents) { + return meta; + } + } + SessionMeta::default() } +fn save_session_meta(state: &AppState) { + let meta = SessionMeta { + workdirs: state.session_workdirs.lock().unwrap().clone(), + ssh_configs: state.session_ssh_configs.lock().unwrap().clone(), + }; + if let Ok(json) = serde_json::to_string_pretty(&meta) { + let _ = std::fs::write(&state.session_meta_path, json); + } +} + + // ── Config commands ── #[derive(Serialize)] @@ -34,6 +70,7 @@ struct ConfigInfo { #[tauri::command] fn get_config(state: State) -> ConfigInfo { let model = state.model.lock().unwrap().clone(); + let project_dir = state.project_dir.lock().unwrap().clone(); let (provider, _) = if model.contains('/') { let pos = model.find('/').unwrap(); (model[..pos].to_string(), model[pos + 1..].to_string()) @@ -45,7 +82,7 @@ fn get_config(state: State) -> ConfigInfo { provider, model, has_api_key: true, - project_dir: state.project_dir.clone(), + project_dir, server_running: true, server_version: None, } @@ -63,15 +100,147 @@ fn set_model(model: String, state: State) -> Result<(), String> { #[tauri::command] async fn get_sessions(state: State<'_, AppState>) -> Result, String> { - state.client.list_sessions().await + let mut sessions = state.client.list_sessions().await?; + + // OpenCode doesn't persist workdir/ssh_config_id β€” overlay from our local maps + let workdirs = state.session_workdirs.lock().unwrap().clone(); + let ssh_configs = state.session_ssh_configs.lock().unwrap().clone(); + + for session in &mut sessions { + if session.workdir.is_none() { + session.workdir = workdirs.get(&session.id).cloned(); + } + if session.ssh_config_id.is_none() { + session.ssh_config_id = ssh_configs.get(&session.id).cloned(); + } + } + + Ok(sessions) } #[tauri::command] async fn create_session( title: Option, + workdir: Option, + ssh_config_id: Option, state: State<'_, AppState>, ) -> Result { - state.client.create_session(title).await + // For SSH sessions, restart server in a temp workspace BEFORE creating the session. + // This ensures the session ID is valid in the server that will handle SSE + prompts. + if let Some(ref ssh_id) = ssh_config_id { + if !ssh_id.is_empty() { + let temp_workdir = std::env::temp_dir() + .join("branchcode-ssh") + .join(ssh_id); + let _ = std::fs::create_dir_all(&temp_workdir); + + // OpenCode requires a git repo for its file tools (write/edit). + // Without .git, tool calls hang indefinitely. + if !temp_workdir.join(".git").exists() { + println!("[SSH] Initializing git repo in temp workspace"); + let _ = std::process::Command::new("git") + .args(["init"]) + .current_dir(&temp_workdir) + .output(); + let _ = std::fs::write( + temp_workdir.join(".gitignore"), + ".opencode/\n*.tmp\n", + ); + let _ = std::process::Command::new("git") + .args(["add", "."]) + .current_dir(&temp_workdir) + .output(); + let _ = std::process::Command::new("git") + .args(["commit", "-m", "init", "--allow-empty"]) + .current_dir(&temp_workdir) + .output(); + } + + // Auto-approve all tool calls in the SSH workspace. + // Without this, OpenCode's write/edit tools wait for user approval + // that never comes (we're running headless via API), causing hangs. + let oc_config = temp_workdir.join("opencode.json"); + if !oc_config.exists() { + let _ = std::fs::write( + &oc_config, + r#"{"permission":"allow"}"#, + ); + println!("[SSH] Created opencode.json with auto-approve permissions"); + } + + let temp_str = temp_workdir.to_string_lossy().to_string(); + + // Atomically claim the directory to prevent concurrent restart storms. + // Only the first call that sees a mismatch will actually restart the server. + let needs_restart = { + let mut dir = state.project_dir.lock().unwrap(); + if *dir == temp_str { + false + } else { + *dir = temp_str.clone(); // claim immediately + true + } + }; + + if needs_restart { + println!("[SSH] Switching server to temp workspace: {}", temp_str); + let port = state.server_port; + let mut srv_guard = state.server.lock().await; + + if let Some(ref mut srv) = *srv_guard { + srv.stop(); + } + *srv_guard = None; + + match server::OpenCodeServer::force_start(port, &temp_str).await { + Ok(new_server) => { + println!("[SSH] Server ready in: {}", temp_str); + *srv_guard = Some(new_server); + } + Err(e) => { + return Err(format!("Failed to start SSH workspace server: {}", e)); + } + } + } + } + } + + let mut session = state.client.create_session(title, workdir.clone(), ssh_config_id.clone()).await?; + + // Store the workdir ourselves β€” the OpenCode server doesn't persist it + if let Some(ref wd) = workdir { + if !wd.is_empty() { + println!("[Session] Storing workdir for {}: {}", session.id, wd); + state.session_workdirs.lock().unwrap().insert(session.id.clone(), wd.clone()); + } + } + + // Store the ssh_config_id ourselves β€” the OpenCode server doesn't persist it + if let Some(ref ssh_id) = ssh_config_id { + if !ssh_id.is_empty() { + println!("[Session] Storing ssh_config_id for {}: {}", session.id, ssh_id); + state.session_ssh_configs.lock().unwrap().insert(session.id.clone(), ssh_id.clone()); + } + } + + // Ensure the session object itself has these fields for the frontend + if session.workdir.is_none() { + session.workdir = workdir; + } + if session.ssh_config_id.is_none() { + session.ssh_config_id = ssh_config_id; + } + + save_session_meta(&state); + + Ok(session) +} + +#[tauri::command] +fn get_home_dir() -> Result { + dirs::home_dir() + .map(|p| p.to_string_lossy().to_string()) + .ok_or_else(|| "Could not determine home directory".to_string()) } #[tauri::command] @@ -79,7 +248,13 @@ async fn delete_session( session_id: String, state: State<'_, AppState>, ) -> Result { - state.client.delete_session(&session_id).await + let result = state.client.delete_session(&session_id).await; + if result.is_ok() { + state.session_workdirs.lock().unwrap().remove(&session_id); + state.session_ssh_configs.lock().unwrap().remove(&session_id); + save_session_meta(&state); + } + result } #[tauri::command] @@ -108,14 +283,205 @@ async fn send_message( let agent_str = agent.as_deref(); + let session = state.client.get_session(&session_id).await?; + // The OpenCode server doesn't persist workdir/ssh_config_id, so fall back to our local maps + let workdir = session.workdir.clone().or_else(|| { + state.session_workdirs.lock().unwrap().get(&session_id).cloned() + }); + let ssh_config_id = session.ssh_config_id.clone().or_else(|| { + state.session_ssh_configs.lock().unwrap().get(&session_id).cloned() + }); + + // ── Restart server if the session targets a different LOCAL directory (not SSH) ── + // Only restart local server when there's NO ssh_config_id - SSH sessions use remote opencode + if ssh_config_id.is_none() { + if let Some(ref wd) = workdir { + if !wd.is_empty() { + let current_dir = state.project_dir.lock().unwrap().clone(); + if wd != ¤t_dir { + println!("[Server] Session workdir differs: {} -> {}", current_dir, wd); + let port = state.server_port; + + let mut srv_guard = state.server.lock().await; + + if let Some(ref mut srv) = *srv_guard { + srv.stop(); + } + *srv_guard = None; + + match server::OpenCodeServer::force_start(port, wd).await { + Ok(new_server) => { + println!("[Server] Restarted in: {}", wd); + *srv_guard = Some(new_server); + *state.project_dir.lock().unwrap() = wd.clone(); + + let git_svc = git::GitService::new(wd.clone()); + if git_svc.is_git_repo() { + println!("Git repository detected at {}", wd); + } + *state.git.lock().unwrap() = Some(git_svc); + } + Err(e) => { + eprintln!("[Server] Failed to restart in {}: {}", wd, e); + let _ = on_event.send(OcStreamEvent::Error { + message: format!("Failed to start server in {}: {}", wd, e), + }); + return Err(format!("Failed to start server in {}: {}", wd, e)); + } + } + } + } + } + } + + if let Some(ref config_id) = ssh_config_id { + let ssh_state = state.ssh_manager.clone(); + let config_id_owned = config_id.clone(); + + // Ensure SSH is connected + { + let mut mgr = ssh_state.lock().await; + if !mgr.is_connected(&config_id_owned) { + let connect_result = mgr.connect(&config_id_owned).await; + if let Err(e) = connect_result { + let _ = on_event.send(OcStreamEvent::Error { + message: format!("SSH connect failed: {}", e), + }); + return Err(format!("SSH connect failed: {}", e)); + } + } + } + + let remote_dir = workdir.clone().unwrap_or_else(|| "/".to_string()); + + // Gather remote context via SSH (file tree + key files) β€” no downloads + let _ = on_event.send(OcStreamEvent::Status { + message: "Reading remote project...".to_string(), + }); + + let remote_context = { + let mut mgr = ssh_state.lock().await; + match mgr.gather_remote_context(&config_id_owned, &remote_dir).await { + Ok(ctx) => ctx, + Err(e) => { + eprintln!("[SSH] Failed to gather context: {}", e); + format!("## Remote project: {}\n(Could not read file tree)\n", remote_dir) + } + } + }; + + // The temp workspace was already set up by create_session. + // No server restart here β€” SSE connections stay alive. + let temp_workdir = std::env::temp_dir() + .join("branchcode-ssh") + .join(&config_id_owned); + let temp_str = temp_workdir.to_string_lossy().to_string(); + + // Enrich the message: system instructions + remote context + user message + let enriched_message = format!( + "[SYSTEM] You are connected to a remote Linux server via SSH.\n\ + Remote project directory: {}\n\ + IMPORTANT FILE PATH RULES:\n\ + - When creating or editing files, ALWAYS use RELATIVE paths (e.g. \"yo.zig\", \"src/main.rs\")\n\ + - NEVER use absolute paths (no leading /)\n\ + - NEVER use Windows paths\n\ + - Files you create will be automatically uploaded to the remote server\n\n\ + {}\n---\n\n{}", + remote_dir, remote_context, message + ); + + println!("[SSH] Sending with {} chars of remote context", remote_context.len()); + + let _ = on_event.send(OcStreamEvent::Status { + message: format!("{}", model), + }); + + let result = state + .client + .send_message(&session_id, &enriched_message, &model, agent_str, Some(&temp_str), None, &on_event, + Some((temp_str.clone(), remote_dir.clone()))) + .await; + + // After AI finishes, upload any created/modified files to the remote + let created_files = collect_temp_files(&temp_workdir); + if !created_files.is_empty() { + println!("[SSH] Uploading {} files to remote...", created_files.len()); + let _ = on_event.send(OcStreamEvent::Status { + message: format!("Uploading {} files to remote...", created_files.len()), + }); + let mut mgr = ssh_state.lock().await; + for (rel_path, content) in &created_files { + let remote_file = format!( + "{}/{}", + remote_dir.trim_end_matches('/'), + rel_path.replace('\\', "/") + ); + match mgr.sftp_write_file(&config_id_owned, &remote_file, content).await { + Ok(_) => println!("[SSH] Uploaded: {}", remote_file), + Err(e) => eprintln!("[SSH] Upload failed {}: {}", remote_file, e), + } + } + // Clean up temp files after upload + for (rel_path, _) in &created_files { + let local = temp_workdir.join(rel_path); + let _ = std::fs::remove_file(&local); + } + } + + return result.map(|_| ()); + } + + + let workdir_ref: Option<&str> = workdir.as_deref(); + let ssh_ref: Option<&str> = ssh_config_id.as_deref(); + state .client - .send_message(&session_id, &message, &model, agent_str, &on_event) + .send_message(&session_id, &message, &model, agent_str, workdir_ref, ssh_ref, &on_event, None) .await?; Ok(()) } +// ── SSH temp file collection ── + +/// Collect all user-visible files from the temp SSH workspace. +/// Skips .opencode, hidden dirs, and hidden files. +fn collect_temp_files(dir: &std::path::Path) -> Vec<(String, String)> { + let mut files = Vec::new(); + collect_temp_inner(dir, dir, &mut files); + files +} + +fn collect_temp_inner( + base: &std::path::Path, + dir: &std::path::Path, + out: &mut Vec<(String, String)>, +) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + // Skip OpenCode internals, hidden files, and our own config + if name.starts_with('.') || name == "opencode.json" { + continue; + } + if path.is_dir() { + collect_temp_inner(base, &path, out); + } else if path.is_file() { + if let Ok(rel) = path.strip_prefix(base) { + let rel_str = rel.to_string_lossy().to_string(); + if let Ok(content) = std::fs::read_to_string(&path) { + out.push((rel_str, content)); + } + } + } + } +} + // ── File commands (proxy to OpenCode server) ── #[tauri::command] @@ -131,6 +497,61 @@ async fn list_directory( state.client.list_files(Some(&path)).await } +#[tauri::command] +async fn list_local_directory(path: String) -> Result { + use std::fs; + use std::path::Path; + + let dir_path = if path == "." || path.is_empty() { + std::env::current_dir() + .map_err(|e| format!("Failed to get current directory: {}", e))? + } else { + let p = Path::new(&path); + if p.is_relative() { + std::env::current_dir() + .map_err(|e| format!("Failed to get current directory: {}", e))? + .join(p) + } else { + p.to_path_buf() + } + }; + + if !dir_path.exists() { + return Err(format!("Path does not exist: {}", dir_path.display())); + } + if !dir_path.is_dir() { + return Err(format!("Path is not a directory: {}", dir_path.display())); + } + + fn strip_extended_prefix(path: &std::path::Path) -> String { + let s = path.to_string_lossy(); + if s.starts_with("\\\\?\\") { + s[4..].to_string() + } else { + s.to_string() + } + } + + let entries: Vec = fs::read_dir(&dir_path) + .map_err(|e| format!("Failed to read directory: {}", e))? + .filter_map(|entry| { + entry.ok().map(|e| { + let file_path = e.path(); + let name = e.file_name().to_string_lossy().to_string(); + let is_dir = file_path.is_dir(); + serde_json::json!({ + "name": name, + "path": strip_extended_prefix(&file_path), + "is_dir": is_dir, + "type": if is_dir { "directory" } else { "file" } + }) + }) + }) + .collect(); + + Ok(serde_json::json!({ "children": entries })) +} + #[tauri::command] async fn find_files( query: String, @@ -274,14 +695,12 @@ fn get_git_diff_stats(state: State) -> Result, Strin #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - // Try E:\dev\branchcode-test first, fallback to current directory - let project_dir = if std::path::Path::new("E:\\dev\\branchcode-test").exists() { - "E:\\dev\\branchcode-test".to_string() - } else { - std::env::current_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| ".".to_string()) - }; + // Use the current working directory as the default project dir. + // Per-session workdirs are passed via the OpenCode API request body, + // so this only serves as the fallback when no session workdir is set. + let project_dir = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| ".".to_string()); println!("Project directory: {}", project_dir); @@ -308,19 +727,40 @@ pub fn run() { println!("Not a git repository: {}", project_dir); } + // Initialize SSH manager + let ssh_data_dir = dirs::data_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("branchcode"); + + std::fs::create_dir_all(&ssh_data_dir).unwrap_or_default(); + let session_meta_path = ssh_data_dir.join("session_meta.json"); + let session_meta = load_session_meta(&session_meta_path); + + let ssh_state: SshState = Arc::new(tokio::sync::Mutex::new( + ssh_client::SshManager::new(ssh_data_dir), + )); + let state = AppState { client, - project_dir, - model: std::sync::Mutex::new(String::new()), - git: std::sync::Mutex::new(Some(git_service)), + project_dir: Mutex::new(project_dir), + model: Mutex::new(String::new()), + git: Mutex::new(Some(git_service)), pty: Arc::new(Mutex::new(pty::PtyManager::new())), + ssh_manager: ssh_state.clone(), + server: TokioMutex::new(_server.ok()), + server_port: port, + session_workdirs: Mutex::new(session_meta.workdirs), + session_ssh_configs: Mutex::new(session_meta.ssh_configs), + session_meta_path, }; tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_dialog::init()) .manage(state.pty.clone()) + .manage(ssh_state) .manage(state) .invoke_handler(tauri::generate_handler![ get_config, @@ -332,6 +772,7 @@ pub fn run() { send_message, read_file, list_directory, + list_local_directory, find_files, get_model_info, get_providers, @@ -356,6 +797,22 @@ pub fn run() { updater::check_updates, updater::download_and_install, updater::get_release_url, + ssh_client::ssh_list_servers, + ssh_client::ssh_save_server, + ssh_client::ssh_update_server, + ssh_client::ssh_delete_server, + ssh_client::ssh_connect, + ssh_client::ssh_disconnect, + ssh_client::ssh_get_connections, + ssh_client::ssh_list_dir, + ssh_client::ssh_read_file, + ssh_client::ssh_write_file, + ssh_client::ssh_resize_shell, + ssh_client::ssh_spawn_shell, + ssh_client::ssh_write_shell, + ssh_client::ssh_close_shell, + ssh_client::ssh_exec_command, + get_home_dir, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/opencode_client.rs b/src-tauri/src/opencode_client.rs index 8478ee9..656d535 100644 --- a/src-tauri/src/opencode_client.rs +++ b/src-tauri/src/opencode_client.rs @@ -11,6 +11,8 @@ use tokio::sync::oneshot; pub struct OcSession { pub id: String, pub title: Option, + pub workdir: Option, + pub ssh_config_id: Option, pub created_at: Option, pub updated_at: Option, } @@ -199,11 +201,38 @@ impl OpenCodeClient { .map_err(|e| format!("Failed to parse sessions: {}", e)) } - pub async fn create_session(&self, title: Option) -> Result { - let body = match title { - Some(t) if !t.is_empty() => serde_json::json!({ "title": t }), - _ => serde_json::json!({}), - }; + pub async fn get_session(&self, session_id: &str) -> Result { + let resp = self + .client + .get(&format!("{}/session/{}", self.base_url, session_id)) + .send() + .await + .map_err(|e| format!("Failed to get session: {}", e))?; + + resp.json() + .await + .map_err(|e| format!("Failed to parse session: {}", e)) + } + + pub async fn create_session( + &self, + title: Option, + workdir: Option, + ssh_config_id: Option, + ) -> Result { + let mut body = serde_json::Map::new(); + if let Some(t) = title { + if !t.is_empty() { + body.insert("title".to_string(), serde_json::Value::String(t)); + } + } + if let Some(w) = &workdir { + body.insert("workdir".to_string(), serde_json::Value::String(w.clone())); + } + if let Some(s) = ssh_config_id { + body.insert("ssh_config_id".to_string(), serde_json::Value::String(s)); + } + let body = serde_json::Value::Object(body); let resp = self .client @@ -213,9 +242,16 @@ impl OpenCodeClient { .await .map_err(|e| format!("Failed to create session: {}", e))?; - resp.json() + let mut session: OcSession = resp + .json() .await - .map_err(|e| format!("Failed to parse session: {}", e)) + .map_err(|e| format!("Failed to parse session: {}", e))?; + + if session.workdir.is_none() { + session.workdir = workdir; + } + + Ok(session) } pub async fn delete_session(&self, id: &str) -> Result { @@ -244,26 +280,40 @@ impl OpenCodeClient { .await .map_err(|e| format!("Failed to parse messages: {}", e))?; - println!("Raw messages response: {}", serde_json::to_string_pretty(&raw).unwrap_or_default().chars().take(2000).collect::()); - let messages: Vec = serde_json::from_value(raw.clone()) .map_err(|e| format!("Failed to deserialize messages: {}\nRaw: {}", e, &raw.to_string().chars().take(500).collect::()))?; - - // Log first message part types for debugging - if let Some(first) = messages.first() { - println!("First message parts: {:?}", first.parts.iter().map(|p| (&p.part_type, &p.text)).collect::>()); - } + Ok(messages.into_iter().map(OcMessageResponse::normalize).collect()) } + /// Rewrite a local temp path to the remote display path. + fn rewrite_path(path: &str, rewrite: &Option<(String, String)>) -> String { + if let Some((from, to)) = rewrite { + // Handle both forward and backslash variants + let normalized = path.replace('\\', "/"); + let from_normalized = from.replace('\\', "/"); + if normalized.starts_with(&from_normalized) { + let rel = normalized[from_normalized.len()..].trim_start_matches('/'); + if rel.is_empty() { + return to.clone(); + } + return format!("{}/{}", to.trim_end_matches('/'), rel); + } + } + path.to_string() + } + pub async fn send_message( &self, session_id: &str, message: &str, model: &str, agent: Option<&str>, + workdir: Option<&str>, + ssh_config_id: Option<&str>, channel: &Channel, + path_rewrite: Option<(String, String)>, ) -> Result { let (provider, model_id) = if let Some(pos) = model.find('/') { (model[..pos].to_string(), model[pos + 1..].to_string()) @@ -288,6 +338,18 @@ impl OpenCodeClient { } } + if let Some(wd) = workdir { + if !wd.is_empty() { + body["workdir"] = serde_json::json!(wd); + } + } + + if let Some(ssh_id) = ssh_config_id { + if !ssh_id.is_empty() { + body["ssh_config_id"] = serde_json::json!(ssh_id); + } + } + // Start SSE listener with ready signal let (ready_tx, ready_rx) = oneshot::channel(); let (done_tx, mut done_rx) = tokio::sync::mpsc::channel::<(String, Option, Option)>(1); @@ -301,6 +363,7 @@ impl OpenCodeClient { channel.clone(), Some(ready_tx), Some(done_tx), + path_rewrite, )); // Wait briefly for SSE connection so early reasoning isn't missed @@ -412,6 +475,7 @@ impl OpenCodeClient { channel: Channel, ready_tx: Option>, done_tx: Option, Option)>>, + path_rewrite: Option<(String, String)>, ) { let resp = match client .get(&format!("{}/event", base_url)) @@ -667,10 +731,18 @@ impl OpenCodeClient { } "tool" => { if let Some(name) = tool_name { - let input_str = state + let mut input_str = state .get("input") .map(|v| serde_json::to_string_pretty(v).unwrap_or_default()) .unwrap_or_default(); + // Rewrite local temp paths in tool inputs + if let Some((ref from, _)) = path_rewrite { + let from_fwd = from.replace('\\', "/"); + let from_back = from.replace('/', "\\"); + input_str = input_str.replace(&from_fwd, "."); + input_str = input_str.replace(&from_back, "."); + input_str = input_str.replace(from, "."); + } let status = if state_status.is_empty() { "pending".to_string() } else { @@ -727,7 +799,7 @@ impl OpenCodeClient { "file.edited" => { if let Some(file) = props.get("file").and_then(|v| v.as_str()) { let _ = channel.send(OcStreamEvent::FileEdit { - path: file.to_string(), + path: Self::rewrite_path(file, &path_rewrite), }); } } diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs index 3c45b43..a060457 100644 --- a/src-tauri/src/server.rs +++ b/src-tauri/src/server.rs @@ -14,10 +14,32 @@ impl OpenCodeServer { return Ok(Self { process: None, port }); } + Self::spawn_fresh(port, work_dir).await + } + + /// Force-start the server in a new directory, killing anything on the port first. + pub async fn force_start(port: u16, work_dir: &str) -> Result { + // Kill anything on the port β€” we need a clean slate + Self::kill_on_port(port); + tokio::time::sleep(Duration::from_millis(1200)).await; + + // Double-check: if something is STILL alive, kill harder + if Self::is_healthy(port).await { + println!("[Server] Port {} still in use after kill, retrying...", port); + Self::kill_on_port(port); + tokio::time::sleep(Duration::from_millis(1500)).await; + } + + Self::spawn_fresh(port, work_dir).await + } + + /// Spawn a new opencode server process and wait for it to be healthy. + async fn spawn_fresh(port: u16, work_dir: &str) -> Result { println!("Starting OpenCode server on port {} in {}...", port, work_dir); let process = Command::new("opencode") .args(["serve", "--port", &port.to_string()]) .current_dir(work_dir) + .env("OPENCODE_YOLO", "true") // Auto-approve tool calls (headless API mode) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() @@ -39,6 +61,52 @@ impl OpenCodeServer { } } + /// Stop the server: kill owned process AND anything else on the port. + pub fn stop(&mut self) { + if let Some(mut process) = self.process.take() { + println!("[Server] Killing owned opencode process (pid {})", process.id()); + let _ = process.kill(); + let _ = process.wait(); + } + // Always kill anything still on the port (e.g. externally started opencode) + Self::kill_on_port(self.port); + } + + /// Kill any process listening on the given port (platform-specific). + fn kill_on_port(port: u16) { + println!("[Server] Killing all processes on port {}...", port); + + #[cfg(target_os = "windows")] + { + // netstat -ano | findstr :PORT | findstr LISTENING β†’ extract PID β†’ taskkill + if let Ok(output) = Command::new("cmd") + .args(["/C", &format!("netstat -ano | findstr :{} | findstr LISTENING", port)]) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if let Some(pid_str) = line.split_whitespace().last() { + if let Ok(pid) = pid_str.parse::() { + if pid > 0 { + println!("[Server] Killing PID {} on port {}", pid, port); + let _ = Command::new("taskkill") + .args(["/F", "/PID", &pid.to_string()]) + .output(); + } + } + } + } + } + } + + #[cfg(not(target_os = "windows"))] + { + let _ = Command::new("sh") + .args(["-c", &format!("lsof -ti:{} | xargs kill -9 2>/dev/null || true", port)]) + .output(); + } + } + async fn is_healthy(port: u16) -> bool { let client = match Client::builder() .timeout(Duration::from_secs(2)) diff --git a/src-tauri/src/ssh_client.rs b/src-tauri/src/ssh_client.rs new file mode 100644 index 0000000..1f71b6b --- /dev/null +++ b/src-tauri/src/ssh_client.rs @@ -0,0 +1,801 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{anyhow, Context, Result}; +use russh::keys::load_secret_key; +use russh::*; +use russh_sftp::client::SftpSession; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, State}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::Mutex as TokioMutex; + +// ── Serialized config types ─────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SshServerConfig { + pub id: String, + pub name: String, + pub host: String, + pub port: u16, + pub username: String, + pub auth_method: AuthMethodConfig, + pub default_directory: Option, + pub group: Option, + pub tags: Option>, + pub os: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum AuthMethodConfig { + #[serde(rename = "password")] + Password { password: String }, + #[serde(rename = "key")] + KeyFile { + path: String, + passphrase: Option, + }, +} + +// ── Connection state ────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize)] +pub struct SshConnectionInfo { + pub config_id: String, + pub server_name: String, + pub connected: bool, + pub os: Option, +} + +// ── SFTP file entry ─────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize)] +pub struct SftpFileEntry { + pub name: String, + pub path: String, + pub is_dir: bool, + pub size: u64, +} + +// ── Tauri event payloads ────────────────────────────────────────────────────── + +#[derive(Serialize, Clone)] +struct SshShellData { + id: String, + data: Vec, +} + +#[derive(Serialize, Clone)] +struct SshShellExit { + id: String, +} + +// ── russh client handler ────────────────────────────────────────────────────── + +struct ClientHandler; + +#[async_trait::async_trait] +impl client::Handler for ClientHandler { + type Error = anyhow::Error; + + async fn check_server_key( + &mut self, + _server_public_key: &russh::keys::key::PublicKey, + ) -> std::result::Result { + // Accept all server keys for now (like ssh -o StrictHostKeyChecking=no) + // TODO: implement known_hosts checking + Ok(true) + } +} + +// ── Active connection ───────────────────────────────────────────────────────── + +struct SshConnection { + handle: client::Handle, + config_id: String, + server_name: String, +} + +// ── Shell commands sent from the main thread to the reader task ─────────────── + +enum ShellCmd { + Write(Vec), + Resize(u32, u32), + Close, +} + +// ── Shell handle ────────────────────────────────────────────────────────────── + +struct SshShellHandle { + cmd_tx: tokio::sync::mpsc::UnboundedSender, + conn_id: String, +} + +// ── SSH Manager (main state) ────────────────────────────────────────────────── + +pub struct SshManager { + configs: Vec, + connections: HashMap, + shells: HashMap, + config_path: PathBuf, + next_shell_id: usize, +} + +impl SshManager { + pub fn new(app_data_dir: PathBuf) -> Self { + let config_path = app_data_dir.join("ssh_servers.json"); + let configs = Self::load_configs(&config_path).unwrap_or_default(); + + println!("[SSH] Loaded {} server configs from {:?}", configs.len(), config_path); + + Self { + configs, + connections: HashMap::new(), + shells: HashMap::new(), + config_path, + next_shell_id: 1, + } + } + + fn load_configs(path: &PathBuf) -> Result> { + if !path.exists() { + return Ok(Vec::new()); + } + let data = std::fs::read_to_string(path)?; + let configs: Vec = serde_json::from_str(&data)?; + Ok(configs) + } + + fn save_configs(&self) -> Result<()> { + if let Some(parent) = self.config_path.parent() { + std::fs::create_dir_all(parent)?; + } + let data = serde_json::to_string_pretty(&self.configs)?; + std::fs::write(&self.config_path, data)?; + Ok(()) + } + + // ── Config CRUD ─────────────────────────────────────────────────────────── + + pub fn list_servers(&self) -> Vec { + self.configs.clone() + } + + pub fn add_server(&mut self, mut config: SshServerConfig) -> Result { + if config.id.is_empty() { + config.id = uuid::Uuid::new_v4().to_string(); + } + if config.port == 0 { + config.port = 22; + } + self.configs.push(config.clone()); + self.save_configs()?; + println!("[SSH] Added server: {} ({})", config.name, config.host); + Ok(config) + } + + pub fn update_server(&mut self, config: SshServerConfig) -> Result<()> { + if let Some(existing) = self.configs.iter_mut().find(|c| c.id == config.id) { + *existing = config; + self.save_configs()?; + } + Ok(()) + } + + pub fn remove_server(&mut self, id: &str) -> Result<()> { + self.configs.retain(|c| c.id != id); + self.save_configs()?; + Ok(()) + } + + pub fn get_config(&self, id: &str) -> Option<&SshServerConfig> { + self.configs.iter().find(|c| c.id == id) + } + + // ── Connection management ───────────────────────────────────────────────── + + pub async fn connect(&mut self, config_id: &str) -> Result { + let config = self + .configs + .iter() + .find(|c| c.id == config_id) + .cloned() + .ok_or_else(|| anyhow!("Server config not found: {}", config_id))?; + + println!("[SSH] Connecting to {}@{}:{}", config.username, config.host, config.port); + + let russh_config = client::Config { + ..Default::default() + }; + + let handler = ClientHandler; + let mut handle = client::connect( + Arc::new(russh_config), + (config.host.as_str(), config.port), + handler, + ) + .await + .context(format!("Failed to connect to {}:{}", config.host, config.port))?; + + // Authenticate + let authenticated = match &config.auth_method { + AuthMethodConfig::Password { password } => { + handle + .authenticate_password(&config.username, password) + .await + .context("Password authentication failed")? + } + AuthMethodConfig::KeyFile { path, passphrase } => { + let key_path = shellexpand_path(path); + let secret_key = load_secret_key(&key_path, passphrase.as_deref()) + .context(format!("Failed to load SSH key from {:?}", key_path))?; + handle + .authenticate_publickey(&config.username, Arc::new(secret_key)) + .await + .context("Key authentication failed")? + } + }; + + if !authenticated { + return Err(anyhow!("Authentication failed for {}@{}", config.username, config.host)); + } + + println!("[SSH] Connected and authenticated to {}", config.name); + + let conn = SshConnection { + handle, + config_id: config.id.clone(), + server_name: config.name.clone(), + }; + + let info = SshConnectionInfo { + config_id: config.id.clone(), + server_name: config.name.clone(), + connected: true, + os: config.os.clone(), + }; + + self.connections.insert(config.id, conn); + Ok(info) + } + + pub async fn disconnect(&mut self, config_id: &str) -> Result<()> { + // Send Close to any shells on this connection + let shell_ids: Vec = self.shells.iter() + .filter(|(_, s)| s.conn_id == config_id) + .map(|(id, _)| id.clone()) + .collect(); + for id in &shell_ids { + if let Some(shell) = self.shells.get(id) { + let _ = shell.cmd_tx.send(ShellCmd::Close); + } + } + + if let Some(conn) = self.connections.remove(config_id) { + println!("[SSH] Disconnecting from {}", conn.server_name); + conn.handle + .disconnect(Disconnect::ByApplication, "User disconnected", "en") + .await + .ok(); + } + // Remove associated shells + self.shells.retain(|_, shell| shell.conn_id != config_id); + Ok(()) + } + + pub fn get_connections(&self) -> Vec { + self.connections + .values() + .map(|c| { + let os = self.configs.iter() + .find(|cfg| cfg.id == c.config_id) + .and_then(|cfg| cfg.os.clone()); + SshConnectionInfo { + config_id: c.config_id.clone(), + server_name: c.server_name.clone(), + connected: true, + os, + } + }) + .collect() + } + + pub fn is_connected(&self, config_id: &str) -> bool { + self.connections.contains_key(config_id) + } + + // ── SFTP Operations ─────────────────────────────────────────────────────── + + pub async fn sftp_list_dir(&mut self, config_id: &str, path: &str) -> Result> { + let conn = self + .connections + .get_mut(config_id) + .ok_or_else(|| anyhow!("Not connected: {}", config_id))?; + + let channel = conn.handle.channel_open_session().await?; + channel.request_subsystem(true, "sftp").await?; + let sftp = SftpSession::new(channel.into_stream()).await?; + + let entries = sftp.read_dir(path).await?; + let mut result = Vec::new(); + + for entry in entries { + let name = entry.file_name(); + if name == "." || name == ".." { + continue; + } + let file_path = if path.ends_with('/') { + format!("{}{}", path, name) + } else { + format!("{}/{}", path, name) + }; + result.push(SftpFileEntry { + name, + path: file_path, + is_dir: entry.file_type().is_dir(), + size: entry.metadata().size.unwrap_or(0), + }); + } + + // Sort: dirs first, then alphabetical + result.sort_by(|a, b| { + b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)) + }); + + Ok(result) + } + + pub async fn sftp_read_file(&mut self, config_id: &str, path: &str) -> Result { + let conn = self + .connections + .get_mut(config_id) + .ok_or_else(|| anyhow!("Not connected: {}", config_id))?; + + let channel = conn.handle.channel_open_session().await?; + channel.request_subsystem(true, "sftp").await?; + let sftp = SftpSession::new(channel.into_stream()).await?; + + let mut file = sftp.open(path).await?; + let mut contents = String::new(); + file.read_to_string(&mut contents).await?; + + Ok(contents) + } + + pub async fn sftp_write_file(&mut self, config_id: &str, path: &str, content: &str) -> Result<()> { + let conn = self + .connections + .get_mut(config_id) + .ok_or_else(|| anyhow!("Not connected: {}", config_id))?; + + let channel = conn.handle.channel_open_session().await?; + channel.request_subsystem(true, "sftp").await?; + let sftp = SftpSession::new(channel.into_stream()).await?; + + let mut file = sftp.create(path).await?; + file.write_all(content.as_bytes()).await?; + file.flush().await?; + + Ok(()) + } + + // ── Shell (PTY) ─────────────────────────────────────────────────────────── + + pub async fn spawn_shell( + &mut self, + config_id: &str, + app: AppHandle, + ) -> Result { + let conn = self + .connections + .get_mut(config_id) + .ok_or_else(|| anyhow!("Not connected: {}", config_id))?; + + let channel = conn.handle.channel_open_session().await?; + + // Request a PTY + channel + .request_pty( + true, + "xterm-256color", + 80, + 24, + 0, + 0, + &[], + ) + .await?; + + // Start shell + channel.request_shell(true).await?; + + let shell_id = format!("ssh-shell-{}", self.next_shell_id); + self.next_shell_id += 1; + + // Create the command channel for write/resize/close + let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::(); + + let shell_handle = SshShellHandle { + cmd_tx, + conn_id: config_id.to_string(), + }; + self.shells.insert(shell_id.clone(), shell_handle); + + // Spawn the shell task β€” owns the Channel exclusively + let read_id = shell_id.clone(); + tokio::spawn(async move { + let mut channel = channel; + loop { + tokio::select! { + // Incoming data from remote + msg = channel.wait() => { + match msg { + Some(ChannelMsg::Data { ref data }) => { + let _ = app.emit( + "ssh:data", + SshShellData { + id: read_id.clone(), + data: data.to_vec(), + }, + ); + } + Some(ChannelMsg::ExtendedData { ref data, .. }) => { + let _ = app.emit( + "ssh:data", + SshShellData { + id: read_id.clone(), + data: data.to_vec(), + }, + ); + } + Some(ChannelMsg::Eof) | None => break, + _ => {} + } + } + // Commands from the main thread (write, resize, close) + cmd = cmd_rx.recv() => { + match cmd { + Some(ShellCmd::Write(data)) => { + let _ = channel.data(&data[..]).await; + } + Some(ShellCmd::Resize(cols, rows)) => { + let _ = channel.window_change(cols, rows, 0, 0).await; + } + Some(ShellCmd::Close) | None => { + let _ = channel.eof().await; + break; + } + } + } + } + } + let _ = app.emit("ssh:exit", SshShellExit { id: read_id }); + }); + + Ok(shell_id) + } + + pub fn write_shell(&self, shell_id: &str, data: &[u8]) -> Result<()> { + let shell = self + .shells + .get(shell_id) + .ok_or_else(|| anyhow!("Shell not found: {}", shell_id))?; + + shell.cmd_tx.send(ShellCmd::Write(data.to_vec())) + .map_err(|_| anyhow!("Shell task has exited"))?; + Ok(()) + } + + pub fn resize_shell(&self, shell_id: &str, cols: u32, rows: u32) -> Result<()> { + let shell = self + .shells + .get(shell_id) + .ok_or_else(|| anyhow!("Shell not found: {}", shell_id))?; + + shell.cmd_tx.send(ShellCmd::Resize(cols, rows)) + .map_err(|_| anyhow!("Shell task has exited"))?; + Ok(()) + } + + pub fn close_shell(&mut self, shell_id: &str) { + if let Some(shell) = self.shells.remove(shell_id) { + let _ = shell.cmd_tx.send(ShellCmd::Close); + } + } + + // ── Remote Commands ─────────────────────────────────────────────────────── + + pub async fn exec_command(&mut self, config_id: &str, cmd: &str) -> Result { + let conn = self + .connections + .get_mut(config_id) + .ok_or_else(|| anyhow!("Not connected: {}", config_id))?; + + let channel = conn.handle.channel_open_session().await?; + channel.exec(true, cmd).await?; + + let mut output = Vec::new(); + let mut stream = channel.into_stream(); + let mut buf = [0u8; 4096]; + + loop { + match stream.read(&mut buf).await { + Ok(0) => break, + Ok(n) => output.extend_from_slice(&buf[..n]), + Err(_) => break, + } + } + + Ok(String::from_utf8_lossy(&output).to_string()) + } + + // ── Remote Context (no file downloads) ───────────────────────────────────── + + /// Gather context about a remote directory via SSH for the AI. + /// Returns a string with the file tree and contents of key config/source files. + /// Nothing is downloaded to disk β€” everything stays in memory as prompt context. + pub async fn gather_remote_context( + &mut self, + config_id: &str, + remote_path: &str, + ) -> Result { + let mut context = String::new(); + context.push_str(&format!("## Remote project: {}\n\n", remote_path)); + + // Get file tree via `find` with RELATIVE paths (so the AI uses relative paths for file ops) + let tree_cmd = format!( + "cd '{}' && find . -maxdepth 3 -not -path '*/node_modules/*' -not -path '*/.git/*' \ + -not -path '*/target/*' -not -path '*/__pycache__/*' \ + -not -path '*/dist/*' -not -path '*/.next/*' \ + -not -path '*/.cache/*' -not -path '*/vendor/*' \ + -not -path '*/logs/*' -not -path '*/crash-reports/*' \ + -not -path '*/libraries/*' -not -path '*/versions/*' \ + -not -path '*/mods/*' -not -path '*/world/*' \ + -not -path '*/saves/*' -not -path '*/structures/*' \ + 2>/dev/null | head -500", + remote_path + ); + + match self.exec_command(config_id, &tree_cmd).await { + Ok(tree) => { + let tree = tree.trim(); + if !tree.is_empty() { + context.push_str("### File tree\n```\n"); + context.push_str(tree); + context.push_str("\n```\n\n"); + } + } + Err(e) => { + eprintln!("[SSH Context] Failed to get file tree: {}", e); + } + } + + // Auto-read key project files if they exist (small text configs only) + let key_files = [ + "README.md", "readme.md", "README.txt", + "package.json", "Cargo.toml", "go.mod", "pom.xml", + "requirements.txt", "pyproject.toml", "setup.py", + "docker-compose.yml", "docker-compose.yaml", "Dockerfile", + ".env.example", "Makefile", + "server.properties", // Minecraft + ]; + + let mut files_read = 0; + for filename in &key_files { + if files_read >= 5 { + break; // Don't overload the context + } + let file_path = format!("{}/{}", remote_path.trim_end_matches('/'), filename); + // Check if file exists and read it (only if <50KB) + let read_cmd = format!( + "test -f '{}' && wc -c < '{}' | tr -d ' '", + file_path, file_path + ); + match self.exec_command(config_id, &read_cmd).await { + Ok(size_str) => { + let size_str = size_str.trim(); + if size_str.is_empty() { + continue; // File doesn't exist + } + let size: u64 = size_str.parse().unwrap_or(0); + if size == 0 || size > 50_000 { + continue; // Skip empty or large files + } + match self.exec_command(config_id, &format!("cat '{}'", file_path)).await { + Ok(content) => { + let content = content.trim(); + if !content.is_empty() { + context.push_str(&format!("### {}\n```\n", filename)); + // Truncate very long files in context + if content.len() > 3000 { + context.push_str(&content[..3000]); + context.push_str("\n... (truncated)"); + } else { + context.push_str(content); + } + context.push_str("\n```\n\n"); + files_read += 1; + println!("[SSH Context] Read {} ({} bytes)", filename, content.len()); + } + } + Err(_) => {} + } + } + Err(_) => {} + } + } + + Ok(context) + } +} +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn shellexpand_path(path: &str) -> PathBuf { + let expanded = if path.starts_with('~') { + if let Some(home) = dirs::home_dir() { + path.replacen('~', &home.to_string_lossy(), 1) + } else { + path.to_string() + } + } else { + path.to_string() + }; + PathBuf::from(expanded) +} + +// ── Tauri State ─────────────────────────────────────────────────────────────── + +pub type SshState = Arc>; + +// ── Tauri Commands ──────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn ssh_list_servers(state: State<'_, SshState>) -> Result, String> { + let mgr = state.lock().await; + Ok(mgr.list_servers()) +} + +#[tauri::command] +pub async fn ssh_save_server( + config: SshServerConfig, + state: State<'_, SshState>, +) -> Result { + let mut mgr = state.lock().await; + mgr.add_server(config).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn ssh_update_server( + config: SshServerConfig, + state: State<'_, SshState>, +) -> Result<(), String> { + let mut mgr = state.lock().await; + mgr.update_server(config).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn ssh_delete_server(id: String, state: State<'_, SshState>) -> Result<(), String> { + let mut mgr = state.lock().await; + mgr.remove_server(&id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn ssh_connect( + config_id: String, + state: State<'_, SshState>, +) -> Result { + let mut mgr = state.lock().await; + mgr.connect(&config_id).await.map_err(|e| { + let err_msg = format!("{:#}", e); + eprintln!("[SSH ERROR] Connection failed: {}", err_msg); + err_msg + }) +} + +#[tauri::command] +pub async fn ssh_disconnect(config_id: String, state: State<'_, SshState>) -> Result<(), String> { + let mut mgr = state.lock().await; + mgr.disconnect(&config_id).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn ssh_get_connections( + state: State<'_, SshState>, +) -> Result, String> { + let mgr = state.lock().await; + Ok(mgr.get_connections()) +} + +#[tauri::command] +pub async fn ssh_list_dir( + config_id: String, + path: String, + state: State<'_, SshState>, +) -> Result, String> { + let mut mgr = state.lock().await; + mgr.sftp_list_dir(&config_id, &path) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn ssh_read_file( + config_id: String, + path: String, + state: State<'_, SshState>, +) -> Result { + let mut mgr = state.lock().await; + mgr.sftp_read_file(&config_id, &path) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn ssh_write_file( + config_id: String, + path: String, + content: String, + state: State<'_, SshState>, +) -> Result<(), String> { + let mut mgr = state.lock().await; + mgr.sftp_write_file(&config_id, &path, &content) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn ssh_spawn_shell( + config_id: String, + state: State<'_, SshState>, + app: AppHandle, +) -> Result { + let mut mgr = state.lock().await; + mgr.spawn_shell(&config_id, app) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn ssh_write_shell( + shell_id: String, + data: String, + state: State<'_, SshState>, +) -> Result<(), String> { + let mgr = state.lock().await; + mgr.write_shell(&shell_id, data.as_bytes()) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn ssh_close_shell(shell_id: String, state: State<'_, SshState>) -> Result<(), String> { + let mut mgr = state.lock().await; + mgr.close_shell(&shell_id); + Ok(()) +} + +#[tauri::command] +pub async fn ssh_resize_shell( + shell_id: String, + cols: u32, + rows: u32, + state: State<'_, SshState>, +) -> Result<(), String> { + let mgr = state.lock().await; + mgr.resize_shell(&shell_id, cols, rows) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn ssh_exec_command( + config_id: String, + command: String, + state: State<'_, SshState>, +) -> Result { + let mut mgr = state.lock().await; + mgr.exec_command(&config_id, &command) + .await + .map_err(|e| e.to_string()) +} + diff --git a/src/components/App.tsx b/src/components/App.tsx index bc4fb53..0d094df 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -22,18 +22,24 @@ import { Square, Star, Search, + Globe, } from 'lucide-react'; import { useChat } from '../hooks/useChat'; import { useSessions } from '../hooks/useSessions'; import { useFileTree } from '../hooks/useFileTree'; +import { useRemoteFileTree } from '../hooks/useRemoteFileTree'; import { useGit } from '../hooks/useGit'; +import { useSsh } from '../hooks/useSsh'; import { SettingsModal } from './Settings'; import UpdateModal from './UpdateModal'; import { ChatMessages, MessagePlaceholder } from './ChatMessages'; import { GitPanel } from './GitPanel'; import { TerminalPanel } from './TerminalPanel'; +import { TopBar } from './TopBar'; import { useVirtualMessages } from '../hooks/useVirtualScroll'; -import { getConfig, setModel, getModelInfo, getProviders, getAvailableModels, type ConfigInfo, type ProviderInfo } from '../lib/tauri'; +import { getConfig, setModel, getModelInfo, getProviders, getAvailableModels, sshListServers, type ConfigInfo, type ProviderInfo, type SshServerConfig } from '../lib/tauri'; +import { DirectoryPickerModal, type SessionSource } from './DirectoryPickerModal'; +import { OsIcon } from './ssh/OsIcons'; // ── Logo ── @@ -253,7 +259,7 @@ function SidebarItem({
{icon ? ( @@ -277,6 +283,51 @@ function SidebarItem({ ); } +function ProjectFolder({ + name, + icon, + children, + defaultOpen = false +}: { + name: string; + icon: React.ReactNode; + children: React.ReactNode; + defaultOpen?: boolean; +}) { + const [isOpen, setIsOpen] = useState(defaultOpen); + return ( +
+ + + {isOpen && ( + +
+ {children} +
+
+ )} +
+
+ ); +} + function SuggestionCard({ icon, iconBg, @@ -660,6 +711,9 @@ export default function App() { const [availableModels, setAvailableModels] = useState([]); const [showFileTree, setShowFileTree] = useState(false); const [showGitPanel, setShowGitPanel] = useState(false); + + // SSH state + const ssh = useSsh(); // Terminal sizing state const [showTerminal, setShowTerminal] = useState(false); @@ -670,6 +724,11 @@ export default function App() { const[showSettings, setShowSettings] = useState(false); const [showUpdateModal, setShowUpdateModal] = useState(false); const [appMode, setAppMode] = useState<'plan' | 'build'>('build'); + + // Session source for working directory + const [sessionSource, setSessionSource] = useState(null); + const [showDirectoryPicker, setShowDirectoryPicker] = useState(false); + const [savedSshServers, setSavedSshServers] = useState([]); const isVisible = isPinned || isHovered; const messagesEndRef = useRef(null); @@ -678,9 +737,10 @@ export default function App() { const suppressAutoLoadSessionRef = useRef(null); const { messages, isStreaming, isLoading, status, send, loadMessages, clearMessages, getSessionUsage } = useChat(); - const { sessions, activeSessionId, createSession, deleteSession, selectSession } = + const { sessions, activeSessionId, createSession, deleteSession, selectSession, renameSessionLocal } = useSessions(); const { files, loadDirectory } = useFileTree(); + const remoteFileTree = useRemoteFileTree(); const git = useGit(true, 5000); const [contextLimit, setContextLimit] = useState(200000); @@ -700,6 +760,13 @@ export default function App() { useEffect(() => { loadConfig(); }, [loadConfig]); + + // Load SSH servers + useEffect(() => { + sshListServers() + .then(setSavedSshServers) + .catch(console.error); + }, []); const findContextLimit = useCallback((modelId: string): number => { const pCache = providersCache; @@ -742,14 +809,55 @@ export default function App() { }, [config?.project_dir, loadDirectory]); useEffect(() => { - if (!activeSessionId) return; + // If we have an active remote connection, default to listing `.` + if (ssh.activeConnectionId) { + remoteFileTree.loadDirectory(ssh.activeConnectionId, '.'); + } + }, [ssh.activeConnectionId, remoteFileTree.loadDirectory]); - if (suppressAutoLoadSessionRef.current === activeSessionId) { - suppressAutoLoadSessionRef.current = null; + // Destructure to get stable references + const { isConnected, connect: sshConnect, setActiveConnection } = ssh; + const { loadDirectory: loadRemoteDirectory } = remoteFileTree; + + const lastProcessedSessionIdRef = useRef(null); + + useEffect(() => { + if (!activeSessionId) { + lastProcessedSessionIdRef.current = null; return; } - loadMessages(activeSessionId); - },[activeSessionId, loadMessages]); + + if (lastProcessedSessionIdRef.current !== activeSessionId) { + lastProcessedSessionIdRef.current = activeSessionId; + + // Auto-connect and set context if this is an SSH session + const session = sessions.find(s => s.id === activeSessionId); + if (session && session.ssh_config_id) { + setSessionSource({ path: session.workdir || '', configId: session.ssh_config_id }); + if (!isConnected(session.ssh_config_id)) { + sshConnect(session.ssh_config_id).catch(console.error); + } + setActiveConnection(session.ssh_config_id); + loadRemoteDirectory(session.ssh_config_id, session.workdir || '.'); + } else { + setSessionSource(null); + } + + if (suppressAutoLoadSessionRef.current === activeSessionId) { + suppressAutoLoadSessionRef.current = null; + } else { + loadMessages(activeSessionId); + } + } + }, [ + activeSessionId, + sessions, + loadMessages, + isConnected, + sshConnect, + setActiveConnection, + loadRemoteDirectory + ]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); @@ -784,7 +892,12 @@ export default function App() { let sessionId = activeSessionId; if (!sessionId) { - const session = await createSession(text.slice(0, 50)); + // Pass workdir and sshConfigId from directory picker selection + const session = await createSession( + text.slice(0, 50), + sessionSource?.path, + sessionSource?.configId, + ); if (!session) return; sessionId = session.id; @@ -793,7 +906,7 @@ export default function App() { await send(sessionId, text, appMode); setInput(''); - },[input, isStreaming, config?.model, activeSessionId, createSession, send, appMode]); + },[input, isStreaming, config?.model, activeSessionId, createSession, send, appMode, sessionSource]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -803,8 +916,17 @@ export default function App() { }; const handleNewChat = async () => { - await createSession(); clearMessages(); + setSessionSource(null); + selectSession(''); // Clear active session so handleSend creates a new one + setShowDirectoryPicker(true); + }; + + const handleDirectorySelect = async (path: string, configId?: string) => { + // Don't create a session yet β€” just remember the source so handleSend + // can pass it when the user actually sends their first message. + setSessionSource({ path, configId }); + setShowDirectoryPicker(false); }; // ── Drag & Resize Logic for Terminal Panel ── @@ -929,63 +1051,85 @@ export default function App() {
- CHATS + THREADS
-
- {sessions.map((session) => ( - } - label={session.title || 'Untitled'} - active={session.id === activeSessionId} - onClick={() => selectSession(session.id)} - onDelete={() => deleteSession(session.id)} - /> - ))} - {sessions.length === 0 && ( -
No chats yet
- )} +
+ {/* Local Workspace Project */} + } + defaultOpen={true} + > + {sessions.filter(s => !s.ssh_config_id).map((session) => ( + selectSession(session.id)} + onDelete={() => deleteSession(session.id)} + /> + ))} + {sessions.filter(s => !s.ssh_config_id).length === 0 && ( +
No threads yet
+ )} +
+ + {/* Remote SSH Sessions β€” grouped by workdir */} + {(() => { + const sshSessions = sessions.filter(s => !!s.ssh_config_id); + // Group by ssh_config_id + workdir + const groups = new Map(); + for (const s of sshSessions) { + const key = `${s.ssh_config_id}::${s.workdir || ''}`; + if (!groups.has(key)) { + const dirName = s.workdir + ? s.workdir.split('/').filter(Boolean).pop() || s.workdir + : 'remote'; + groups.set(key, { serverId: s.ssh_config_id!, dirName, sessions: [] }); + } + groups.get(key)!.sessions.push(s); + } + return Array.from(groups.entries()).map(([key, group]) => { + // Find the server config for its OS icon + const serverConfig = savedSshServers.find(srv => srv.id === group.serverId); + const osType = serverConfig?.os || 'linux'; + return ( + } + defaultOpen={true} + > + {group.sessions.map((session) => ( + selectSession(session.id)} + onDelete={() => deleteSession(session.id)} + /> + ))} + + ); + }); + })()}
- - {showFileTree && ( -
- {Object.entries(files).map(([name, info]) => { - const entry = info as Record; - const isDir = entry.type === 'directory'; - return ( - : } - label={name} - onClick={() => { - if (isDir && entry.path) loadDirectory(entry.path as string); - }} - /> - ); - })} -
- )} +
@@ -1168,6 +1312,19 @@ export default function App() { ) : ( <> + {/* ── Top Bar ── */} + s.id === activeSessionId)?.title || 'New Chat'} + projectDir={config?.project_dir} + sshConnections={ssh.connections} + onTitleChange={(newTitle) => { + if (activeSessionId) { + renameSessionLocal(activeSessionId, newTitle); + } + }} + gitStatus={git.status} + /> + {/* ── Chat ── */} @@ -1301,31 +1458,54 @@ export default function App() {
{/* Git Panel - Right Sidebar */} - - {showGitPanel && ( - - - - )} - + +
+ { + setShowTerminal(true); + window.dispatchEvent(new CustomEvent('spawn-ssh-terminal', { + detail: { configId, serverName } + })); + }} + isOpen={showGitPanel} + onToggle={setShowGitPanel} + /> +
+
+ + {/* ── Directory Picker Modal for New Session ── */} + setShowDirectoryPicker(false)} + onSelect={handleDirectorySelect} + initialMode={sessionSource?.configId ? 'ssh' : 'local'} + sshConnections={ssh.connections} + savedSshServers={savedSshServers} + /> ); } \ No newline at end of file diff --git a/src/components/DirectoryPickerModal.tsx b/src/components/DirectoryPickerModal.tsx new file mode 100644 index 0000000..dd9a4d8 --- /dev/null +++ b/src/components/DirectoryPickerModal.tsx @@ -0,0 +1,822 @@ +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { + CornerLeftUp, + Loader2, + Search, + HardDrive, + AlertCircle, + Command, +} from 'lucide-react'; +import { listLocalDirectory, sshListDir, sshConnect, sshDisconnect, type SshConnectionInfo, type SshServerConfig, getHomeDir } from '../lib/tauri'; +import { OsIcon, OS_OPTIONS, type OsType } from './ssh/OsIcons'; + +interface DirectoryPickerModalProps { + isOpen: boolean; + onClose: () => void; + onSelect: (path: string, configId?: string) => void; + initialPath?: string; + sshConnections?: SshConnectionInfo[]; + savedSshServers?: SshServerConfig[]; + initialMode?: 'local' | 'ssh'; +} + +interface FileItem { + name: string; + path: string; + isDir: boolean; +} + +const easeOut = [0.16, 1, 0.3, 1] as const; +const easeSnap = [0.32, 0.72, 0, 1] as const; + +/* ── Helpers ─────────────────────────────────────────────────────────── */ + +const Kbd: React.FC<{ children: React.ReactNode; className?: string }> = ({ + children, + className = '', +}) => ( + + {children} + +); + +const DirectoryIcon: React.FC<{ selected?: boolean; className?: string }> = ({ + selected = false, + className = '', +}) => ( + +); + +function getOsVisuals(conn: SshConnectionInfo): { os: OsType; tint: string } { + const validIds = new Set(OS_OPTIONS.map((o) => o.id)); + const savedOs = (conn as any)?.os as OsType | undefined | null; + if (savedOs && validIds.has(savedOs)) { + return getOsVisualsFromOsType(savedOs); + } + const osFromName = 'linux'; + return getOsVisualsFromOsType(osFromName); +} + +function getOsVisualsFromOsType(os: OsType): { os: OsType; tint: string } { + const tintMap: Record = { + linux: '#D5D9E0', + ubuntu: '#E95420', + debian: '#D70A53', + raspberrypi: '#C51A4A', + fedora: '#51A2DA', + redhat: '#EE0000', + nixos: '#7EBAE4', + gentoo: '#54487A', + freebsd: '#AB2B28', + arch: '#1793D1', + alpine: '#2D9CDB', + mint: '#87CF3E', + kali: '#367BF0', + macos: '#A2AAAD', + windows: '#00A4EF', + }; + return { os, tint: tintMap[os] || '#D5D9E0' }; +} + +export interface SessionSource { + path: string; + configId?: string; +} + +/* ── Component ───────────────────────────────────────────────────────── */ + +export function DirectoryPickerModal({ + isOpen, + onClose, + onSelect, + initialPath = '.', + sshConnections = [], + savedSshServers = [], + initialMode = 'local', +}: DirectoryPickerModalProps) { + const [activeConfigId, setActiveConfigId] = useState(undefined); + const [connectingId, setConnectingId] = useState(null); + const [currentPath, setCurrentPath] = useState(initialPath || '.'); + const [inputValue, setInputValue] = useState(initialPath || '.'); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + const [error, setError] = useState(null); + const [homeDir, setHomeDir] = useState('~'); + + useEffect(() => { + if (isOpen && !hasOpenedRef.current) { + hasOpenedRef.current = true; + getHomeDir().then((home) => { + setHomeDir(home); + const firstServer = savedSshServers[0]; + const isSSH = initialMode === 'ssh' && !!firstServer; + const initial = isSSH && firstServer ? (firstServer.default_directory || '/') : home; + fetchItems(initial, activeConfigId); + setCurrentPath(initial); + setInputValue(initial); + }).catch(() => { + const firstServer = savedSshServers[0]; + const isSSH = initialMode === 'ssh' && !!firstServer; + const fallback = isSSH && firstServer ? (firstServer.default_directory || '/') : '.'; + fetchItems(fallback, activeConfigId); + setCurrentPath(fallback); + setInputValue(fallback); + }); + } + }, [isOpen]); + + const inputRef = useRef(null); + const listRef = useRef(null); + const prevInputLength = useRef(inputValue.length); + const isKeyboardNav = useRef(false); + const hasOpenedRef = useRef(false); + + const activeConnection = useMemo( + () => savedSshServers.find((s) => s.id === activeConfigId), + [savedSshServers, activeConfigId] + ); + + const normalizePath = (path: string, isSSH: boolean) => { + let p = (path || '').trim(); + if (!isSSH) { + if (/^\/[A-Z]:/i.test(p)) p = p.substring(1); + if (/^[A-Z]:$/i.test(p)) p += '\\'; + } + return p || (isSSH ? '/' : '.'); + }; + + const fetchItems = useCallback(async (path: string, configId?: string) => { + setLoading(true); + setError(null); + const isSSH = !!configId; + const targetPath = normalizePath(path, isSSH); + + try { + let results: FileItem[] = []; + + if (configId) { + const sftpItems = await sshListDir(configId, targetPath); + results = sftpItems + .filter((i) => i.is_dir) + .map((i) => ({ name: i.name, path: i.path, isDir: true })); + } else { + const localItems = (await listLocalDirectory(targetPath)) as any; + + let rawEntries: any[] = []; + if (Array.isArray(localItems)) { + rawEntries = localItems; + } else if (localItems && typeof localItems === 'object') { + rawEntries = + localItems.children || + localItems.entries || + localItems.data?.children || + localItems.data?.entries || + localItems.files || + Object.values(localItems).find((v) => Array.isArray(v)) || + Object.values(localItems); + } + + results = (Array.isArray(rawEntries) ? rawEntries : []) + .map((e: any): FileItem | null => { + if (typeof e !== 'object' || !e) return null; + const name = e.name || e.path?.split(/[\\/]/).pop() || ''; + const isDir = + e.type === 'directory' || + e.is_dir === true || + e.isDir === true || + !!e.children || + (e.path && !e.path.includes('.')); + + if (!isDir || !name || name === '.' || name === '..') return null; + return { name, path: e.path || '', isDir: !!isDir }; + }) + .filter((i): i is FileItem => i !== null); + } + + results.sort((a, b) => a.name.localeCompare(b.name)); + setItems(results); + isKeyboardNav.current = true; + setSelectedIndex(0); + setCurrentPath(targetPath); + setInputValue(targetPath); + } catch (err) { + console.error('Directory Picker Error:', err); + setError(String(err)); + setItems([]); + setCurrentPath(targetPath); + setInputValue(targetPath); + } finally { + setLoading(false); + } + }, []); + + const handleContextSwitch = async (configId: string | undefined) => { + if (configId === activeConfigId) return; + + if (configId) { + setConnectingId(configId); + try { + await sshConnect(configId); + } catch (e) { + console.error('Failed to connect:', e); + setConnectingId(null); + return; + } + } + + setActiveConfigId(configId); + setConnectingId(null); + const newPath = configId ? '/' : initialPath; + fetchItems(newPath, configId); + }; + + const disconnectIfNeeded = useCallback(async () => { + if (connectingId) { + try { + await sshDisconnect(connectingId); + } catch {} + setConnectingId(null); + } + }, [connectingId]); + + /* Open / focus / initial fetch */ + useEffect(() => { + if (isOpen) { + if (!hasOpenedRef.current) { + hasOpenedRef.current = true; + + let startConfigId = activeConfigId; + if (initialMode === 'ssh' && !startConfigId && savedSshServers.length > 0) { + startConfigId = savedSshServers[0]?.id; + setActiveConfigId(startConfigId); + } else if (initialMode === 'local' && startConfigId) { + startConfigId = undefined; + setActiveConfigId(undefined); + } + + const path = initialPath || '.'; + setInputValue(path); + prevInputLength.current = path.length; + fetchItems(path, startConfigId); + + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 50); + } + } else { + hasOpenedRef.current = false; + } + }, [isOpen, initialPath, fetchItems, activeConfigId, initialMode, savedSshServers]); + + /* Scroll selected item into view β€” keyboard only */ + useEffect(() => { + if (!listRef.current || !isKeyboardNav.current) return; + const targetEl = listRef.current.querySelector( + `[data-select-index="${selectedIndex}"]` + ) as HTMLElement | null; + if (targetEl) { + targetEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + isKeyboardNav.current = false; + }, [selectedIndex]); + + const handleItemSelect = useCallback( + (item: FileItem | '..') => { + if (item === '..') { + const isSSH = !!activeConfigId; + const isWindows = + !isSSH && (currentPath.includes('\\') || /^[A-Z]:/i.test(currentPath)); + const separator = isWindows ? '\\' : '/'; + const parts = currentPath.split(/[\\/]/).filter(Boolean); + + if (parts.length > 0) { + parts.pop(); + let newPath = parts.join(separator); + if (isWindows && newPath && /^[A-Z]:$/i.test(newPath)) { + newPath += '\\'; + } + const finalPath = newPath || (isSSH ? '/' : '.'); + fetchItems(finalPath, activeConfigId); + } + } else { + fetchItems(item.path, activeConfigId); + } + inputRef.current?.focus(); + }, + [activeConfigId, currentPath, fetchItems] + ); + + const handleConfirm = useCallback(() => { + onSelect(inputValue, activeConfigId); + onClose(); + }, [inputValue, activeConfigId, onSelect, onClose]); + + const { filteredItems, searchTerm } = useMemo(() => { + const trimmed = inputValue; + if (!trimmed || trimmed === currentPath) return { filteredItems: items, searchTerm: '' }; + + let search = ''; + const normalizedTrimmed = trimmed.replace(/\\/g, '/').toLowerCase(); + const normalizedCurrent = currentPath.replace(/\\/g, '/').toLowerCase(); + + if ( + normalizedTrimmed.startsWith(normalizedCurrent) && + trimmed.length >= currentPath.length + ) { + search = trimmed.slice(currentPath.length); + if (search.startsWith('\\') || search.startsWith('/')) search = search.slice(1); + } else if (!trimmed.includes('\\') && !trimmed.includes('/')) { + search = trimmed; + } + + if (search) { + return { + filteredItems: items.filter((i) => + i.name.toLowerCase().includes(search.toLowerCase()) + ), + searchTerm: search, + }; + } + return { filteredItems: items, searchTerm: '' }; + }, [items, inputValue, currentPath]); + + /* Global keyboard navigation when modal is open */ + useEffect(() => { + if (!isOpen) return; + + const handleGlobalKey = (e: KeyboardEvent) => { + if (!listRef.current) return; + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + isKeyboardNav.current = true; + setSelectedIndex((prev) => Math.min(filteredItems.length, prev + (e.shiftKey ? 10 : 1))); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + e.stopPropagation(); + isKeyboardNav.current = true; + setSelectedIndex((prev) => Math.max(0, prev - (e.shiftKey ? 10 : 1))); + } else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { + e.preventDefault(); + e.stopPropagation(); + isKeyboardNav.current = true; + setSelectedIndex(0); + } + }; + + window.addEventListener('keydown', handleGlobalKey, true); + return () => window.removeEventListener('keydown', handleGlobalKey, true); + }, [isOpen, filteredItems.length]); + + /* Auto-fetch when user types a trailing slash */ + useEffect(() => { + const trimmed = inputValue; + const isAdding = trimmed.length > prevInputLength.current; + prevInputLength.current = trimmed.length; + + if (isAdding && trimmed !== currentPath && (trimmed.endsWith('\\') || trimmed.endsWith('/'))) { + const looksLikePath = + trimmed.includes('\\') || trimmed.includes('/') || /^[A-Z]:/i.test(trimmed); + if (looksLikePath || /^[A-Z]:\\$/i.test(trimmed)) { + fetchItems(trimmed, activeConfigId); + } + } + }, [inputValue, currentPath, fetchItems, activeConfigId]); + + /* Jump to first match on search */ + useEffect(() => { + if (searchTerm && filteredItems.length > 0) { + isKeyboardNav.current = true; + setSelectedIndex(1); + } else if (searchTerm && filteredItems.length === 0) { + isKeyboardNav.current = true; + setSelectedIndex(0); + } + }, [searchTerm, filteredItems.length]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + isKeyboardNav.current = true; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => + Math.min(filteredItems.length, prev + (e.shiftKey ? 10 : 1)) + ); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => Math.max(0, prev - (e.shiftKey ? 10 : 1))); + } else if (e.key === 'Tab') { + e.preventDefault(); + if (e.shiftKey) handleItemSelect('..'); + else if (selectedIndex > 0 && filteredItems[selectedIndex - 1]) + handleItemSelect(filteredItems[selectedIndex - 1]); + else handleItemSelect('..'); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (e.metaKey || e.ctrlKey) { + handleConfirm(); + } else { + const trimmed = inputValue.trim(); + if (selectedIndex > 0 && filteredItems[selectedIndex - 1]) { + handleItemSelect(filteredItems[selectedIndex - 1]); + return; + } else if (selectedIndex === 0 && !searchTerm && trimmed === currentPath) { + handleItemSelect('..'); + return; + } + + const hasTrailingSlash = trimmed.endsWith('\\') || trimmed.endsWith('/'); + const looksLikePath = + trimmed.includes('\\') || trimmed.includes('/') || /^[A-Z]:/i.test(trimmed); + + if (hasTrailingSlash && (looksLikePath || /^[A-Z]:$/i.test(trimmed))) + fetchItems(trimmed.slice(0, -1), activeConfigId); + else if (looksLikePath) fetchItems(trimmed, activeConfigId); + } + } else if (e.key === 'Escape') { + onClose(); + } + }; + + const listKey = `${activeConfigId || 'local'}::${currentPath}`; + + return ( + + {isOpen && ( + + e.stopPropagation()} + > + {/* Ambient top highlight */} +
+ + {/* Header */} +
+
+ + + {savedSshServers.map((server) => { + const isActive = activeConfigId === server.id; + const { os, tint } = getOsVisualsFromOsType(server.os as OsType); + + return ( + + ); + })} +
+ + + {activeConnection && ( + +
+ + SSH + + + )} + +
+ + {/* Input Container */} +
+
+
+ {loading ? ( + + ) : ( + + )} +
+ + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + className=" + flex-1 bg-transparent border-none outline-none + text-[14px] font-medium tracking-tight text-neutral-100 + placeholder:text-neutral-600 + " + placeholder="Search directories or enter path..." + spellCheck={false} + autoComplete="off" + /> + + + Open + + ↡ + +
+
+ + {/* List area */} +
+
+ + + + {activeConnection ? activeConnection.name : 'Local Machine'} + + β€’ + + {filteredItems.length} {filteredItems.length === 1 ? 'item' : 'items'} + + {searchTerm && ( + <> + β€’ + + "{searchTerm}" + + + )} + + +
+ +
+ + {error && ( + +
+ + {error} +
+
+ )} +
+ + {/* Parent */} +
{ + isKeyboardNav.current = false; + setSelectedIndex(0); + }} + onClick={() => handleItemSelect('..')} + className={` + relative mx-1 mt-0.5 mb-1 flex cursor-pointer items-center gap-3 rounded-[8px] px-3 py-2.5 scroll-my-1 + transition-colors duration-100 + ${selectedIndex === 0 ? 'text-white' : 'text-neutral-400 hover:text-neutral-200'} + `} + > + {selectedIndex === 0 && ( + + )} + + .. + + parent + +
+ + {/* Items */} + {filteredItems.map((item, idx) => { + const index = idx + 1; + const isSelected = selectedIndex === index; + + return ( +
{ + isKeyboardNav.current = false; + setSelectedIndex(index); + }} + onClick={() => handleItemSelect(item)} + className={` + relative mx-1 flex cursor-pointer items-center gap-3 rounded-[8px] px-3 py-2.5 scroll-my-1 + ${isSelected ? 'text-white' : 'text-neutral-300'} + `} + > + {isSelected && ( + + )} + + + + + {item.name} + +
+ ); + })} + + {!loading && filteredItems.length === 0 && !error && ( + +
+ +
+ +
+
+

No results

+ {searchTerm && ( +

+ Try a different search term +

+ )} +
+ )} +
+
+ + {/* Footer */} +
+
+
+ ↑ + ↓ + Navigate +
+
+ β‡₯ + Complete +
+
+ ↡ + Browse +
+
+ +
+ esc + Close +
+
+
+ + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/FileExplorer.tsx b/src/components/FileExplorer.tsx new file mode 100644 index 0000000..d171df3 --- /dev/null +++ b/src/components/FileExplorer.tsx @@ -0,0 +1,479 @@ +import { useState, useEffect, useCallback } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { ChevronRight, Globe, HardDrive, Loader2 } from 'lucide-react'; +import { FileIcon } from './FileIcon'; +import { listDirectory as tauriListDirectory } from '../lib/tauri'; +import { sshListDir } from '../lib/tauri'; +import { useSsh } from '../hooks/useSsh'; +import { useChat } from '../hooks/useChat'; + +// ── Types ────────────────────────────────────────────────────────────────────── + +interface FileNode { + name: string; + path: string; + isDir: boolean; + children?: FileNode[]; + isOpen?: boolean; + isLoading?: boolean; +} + +interface TreeNodeProps { + node: FileNode; + level: number; + onToggle: (path: string) => void; + onSelect: (path: string, isDir: boolean) => void; + selectedPath: string | null; + configId?: string; +} + +// ── Constants ────────────────────────────────────────────────────────────────── + +const INDENT_BASE = 6; +const INDENT_PER_LEVEL = 13; +const ROW_HEIGHT = 22; + +// macOS-like easing curve +const ease = [0.32, 0.72, 0, 1] as const; + +// ── Tree Node ────────────────────────────────────────────────────────────────── + +function TreeNode({ node, level, onToggle, onSelect, selectedPath, configId }: TreeNodeProps) { + const isSelected = selectedPath === node.path; + const [isPressed, setIsPressed] = useState(false); + + const handleClick = useCallback(() => { + onSelect(node.path, node.isDir); + if (node.isDir) onToggle(node.path); + }, [node.path, node.isDir, onToggle, onSelect]); + + return ( +
+
setIsPressed(true)} + onMouseUp={() => setIsPressed(false)} + onMouseLeave={() => setIsPressed(false)} + className={`relative flex items-center cursor-default select-none group transition-[background-color] duration-100 ${ + isSelected + ? 'bg-white/[0.06]' + : 'hover:bg-white/[0.025]' + }`} + style={{ + height: `${ROW_HEIGHT}px`, + paddingLeft: `${INDENT_BASE + level * INDENT_PER_LEVEL}px`, + transform: isPressed ? 'scale(0.998)' : 'scale(1)', + transition: 'transform 80ms ease-out, background-color 100ms ease-out', + }} + > + {/* Indent guide lines */} + {Array.from({ length: level }).map((_, i) => ( +
+ ))} + + {/* Selection accent β€” left edge bar */} + {isSelected && ( + + )} + + {/* Chevron / spinner */} +
+ {node.isDir && ( + node.isLoading ? ( + + ) : ( + + + + ) + )} +
+ +
+
+ +
+ + {node.name} + +
+
+ + + {node.isDir && node.isOpen && node.children && ( + + {node.children.map((child, idx) => ( + + + + ))} + + )} + +
+ ); +} + +// ── Tree State Hook ──────────────────────────────────────────────────────────── + +function useTreeState(initialPath: string, configId?: string) { + const [rootNodes, setRootNodes] = useState([]); + const [loading, setLoading] = useState(false); + + const updateNode = ( + nodes: FileNode[], + path: string, + updates: Partial + ): FileNode[] => + nodes.map(n => { + if (n.path === path) return { ...n, ...updates }; + if (n.children) return { ...n, children: updateNode(n.children, path, updates) }; + return n; + }); + + const findNode = (nodes: FileNode[], path: string): FileNode | null => { + for (const n of nodes) { + if (n.path === path) return n; + if (n.children) { + const found = findNode(n.children, path); + if (found) return found; + } + } + return null; + }; + + const fetchDirectory = async (dirPath: string): Promise => { + try { + if (configId) { + const result = await sshListDir(configId, dirPath); + return result + .map(file => ({ + name: file.name, + path: file.path, + isDir: file.is_dir, + })) + .sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + } else { + const result = await tauriListDirectory(dirPath); + const items = Array.isArray(result) ? result : Object.values(result as object); + return items + .map((entry: any) => ({ + name: entry.name || entry.path.split(/[\\/]/).pop() || '', + path: entry.path, + isDir: entry.type === 'directory', + })) + .sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + } + } catch (err) { + console.error('Failed to fetch directory', dirPath, err); + return []; + } + }; + + useEffect(() => { + setLoading(true); + fetchDirectory(initialPath).then(children => { + setRootNodes(children); + setLoading(false); + }); + }, [initialPath, configId]); + + const toggleNode = useCallback( + async (path: string) => { + const node = findNode(rootNodes, path); + if (!node || !node.isDir) return; + + if (node.isOpen) { + setRootNodes(prev => updateNode(prev, path, { isOpen: false })); + } else if (!node.children) { + setRootNodes(prev => updateNode(prev, path, { isLoading: true })); + const children = await fetchDirectory(path); + setRootNodes(prev => + updateNode(prev, path, { isOpen: true, isLoading: false, children }) + ); + } else { + setRootNodes(prev => updateNode(prev, path, { isOpen: true })); + } + }, + [rootNodes, configId] + ); + + return { rootNodes, loading, toggleNode }; +} + +// ── Section Header ───────────────────────────────────────────────────────────── + +interface SectionHeaderProps { + expanded: boolean; + onToggle: () => void; + icon: React.ReactNode; + label: string; + badge?: string; +} + +function SectionHeader({ expanded, onToggle, icon, label, badge }: SectionHeaderProps) { + return ( +
+ + + +
+ {icon} +
+ + {label} + + {badge && ( + + {badge} + + )} +
+ ); +} + +// ── Status Messages ──────────────────────────────────────────────────────────── + +function StatusMessage({ icon, text }: { icon?: React.ReactNode; text: string }) { + return ( +
+ {icon} + {text} +
+ ); +} + +// ── File Explorer ────────────────────────────────────────────────────────────── + +export function FileExplorer() { + const ssh = useSsh(); + + const localProjectName = 'Local Project'; + + const localState = useTreeState('.', undefined); + + const [selectedPath, setSelectedPath] = useState(null); + const [localExpanded, setLocalExpanded] = useState(true); + const [remoteExpanded, setRemoteExpanded] = useState>({}); + + const toggleRemote = (configId: string) => { + setRemoteExpanded(prev => ({ ...prev, [configId]: !prev[configId] })); + }; + + return ( +
+ {/* Header β€” macOS toolbar style */} +
+ Explorer +
+ + {/* Tree container */} +
+ {/* Local Section */} +
+ setLocalExpanded(!localExpanded)} + icon={} + label={localProjectName || 'Local'} + /> + + + {localExpanded && ( + + {localState.loading ? ( + } + text="Loading…" + /> + ) : localState.rootNodes.length === 0 ? ( + + ) : ( +
+ {localState.rootNodes.map(node => ( + setSelectedPath(p)} + selectedPath={selectedPath} + /> + ))} +
+ )} +
+ )} +
+
+ + {/* Remote SSH Sections */} + {ssh.connections.map(conn => ( + toggleRemote(conn.config_id)} + selectedPath={selectedPath} + onSelect={setSelectedPath} + /> + ))} +
+
+ ); +} + +function RemoteTreeSection({ + connection, + expanded, + onToggle, + selectedPath, + onSelect +}: { + connection: any; + expanded: boolean; + onToggle: () => void; + selectedPath: string | null; + onSelect: (p: string) => void; +}) { + const remoteState = useTreeState('.', connection.config_id); + + return ( +
+ {/* Hairline divider */} +
+ + } + label={connection.server_name} + badge="ssh" + /> + + + {expanded && ( + + {remoteState.loading ? ( + } + text="Connecting…" + /> + ) : remoteState.rootNodes.length === 0 ? ( + + ) : ( +
+ {remoteState.rootNodes.map(node => ( + onSelect(p)} + selectedPath={selectedPath} + configId={connection.config_id} + /> + ))} +
+ )} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/FileIcon.tsx b/src/components/FileIcon.tsx new file mode 100644 index 0000000..b87165d --- /dev/null +++ b/src/components/FileIcon.tsx @@ -0,0 +1,154 @@ +import { useState, useEffect } from 'react'; + +interface FileIconProps { + name: string; + isDir: boolean; + isOpen?: boolean; +} + +const getIconName = (name: string, isDir: boolean): string => { + if (isDir) return 'folder'; + + const lowerName = name.toLowerCase(); + const ext = lowerName.split('.').pop() || ''; + + // Specific filename matches + switch (true) { + case lowerName === 'package.json': + case lowerName === 'tsconfig.json': + case ext === 'json': + return 'json'; + case ext === 'ts': + return 'typeScript'; + case ext === 'tsx': + return 'tsx'; + case ext === 'js': + case ext === 'jsx': + return 'javaScript'; + case ext === 'css': + return 'css'; + case ext === 'md': + return 'text'; + case ext === 'rs': + return 'rustFile'; + case ext === 'py': + return 'python'; + case ext === 'go': + return 'go'; + case lowerName === 'go.mod': + return 'gomod'; + case lowerName === 'go.sum': + return 'goSum'; + case lowerName === 'go.work': + return 'goWork'; + case ext === 'java': + return 'java'; + case ext === 'kt': + case ext === 'kts': + return 'kotlin'; + case ext === 'c': + return 'c'; + case ext === 'cpp': + case ext === 'cc': + case ext === 'cxx': + return 'cpp'; + case ext === 'h': + case ext === 'hpp': + return 'h'; + case ext === 'cs': + return 'csharp'; + case ext === 'fs': + return 'font'; + case ext === 'png': + case ext === 'jpg': + case ext === 'jpeg': + case ext === 'gif': + case ext === 'webp': + case ext === 'svg': + return 'image'; + case lowerName === 'dockerfile': + case lowerName === '.dockerignore': + case lowerName.includes('docker'): + return 'docker'; + case lowerName === 'makefile': + return 'makefile'; + case lowerName === 'cmakelists.txt': + return 'cmake'; + case ext === 'toml': + return 'toml'; + case ext === 'yml': + case ext === 'yaml': + case ext === 'env': + case ext === 'lock': + case ext === 'lockb': + case lowerName.includes('config'): + return 'config'; + case lowerName.includes('ignore'): + return 'gitignore'; + case ext === 'sh': + case ext === 'bash': + case ext === 'zsh': + return 'shell'; + case ext === 'rb': + return 'ruby'; + case ext === 'php': + return 'php'; + case ext === 'graphql': + case ext === 'gql': + return 'graphql'; + case lowerName.includes('eslint'): + return 'eslint'; + case ext === 'vue': + return 'vueJs'; + case ext === 'scala': + case ext === 'sbt': + return 'scala'; + case ext === 'tf': + return 'terraform'; + case ext === 'zig': + return 'zig'; + case ext === 'dart': + return 'dart'; + case ext === 'ex': + case ext === 'exs': + return 'elixir'; + case ext === 'erl': + case ext === 'hrl': + return 'erlang'; + case ext === 'gleam': + return 'gleam'; + case ext === 'lua': + return 'lua'; + case ext === 'swift': + return 'swift'; + case ext === 'txt': + return 'text'; + default: + return 'anyType'; + } +}; + +// Icons that we KNOW have _dark variants from our list_dir check +const HAS_DARK_VARIANT = new Set([ + 'anyType', 'c', 'config', 'cpp', 'csharp', 'dart', 'docker', 'elixir', + 'font', 'folder', 'gleam', 'go', 'goSum', 'goWork', 'gomod', 'h', 'hcl', + 'image', 'java', 'javaScript', 'json', 'kotlin', 'makefile', 'php', + 'ruby', 'rustFile', 'scala', 'shell', 'terraform', 'text', 'toml', + 'tsx', 'typeScript', 'zig' +]); + +export function FileIcon({ name, isDir, isOpen }: FileIconProps) { + const iconName = getIconName(name, isDir); + const useDark = HAS_DARK_VARIANT.has(iconName); + const src = `/icons-ide/${iconName}${useDark ? '_dark' : ''}.svg`; + + return ( + {name} + ); +} diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx index 4383f92..68a6ff1 100644 --- a/src/components/GitPanel.tsx +++ b/src/components/GitPanel.tsx @@ -1,17 +1,12 @@ -import { useState, memo } from 'react'; -import { motion, AnimatePresence } from 'motion/react'; +import { useState, memo } from "react"; +import { motion, AnimatePresence } from "motion/react"; import { GitBranch, ChevronRight, ChevronDown, Plus, - Check, X, Loader2, - FileEdit, - FilePlus, - FileX, - ArrowLeftRight, AlertCircle, ListTodo, Undo2, @@ -21,9 +16,19 @@ import { CircleDot, Folder, SquareTerminal, -} from 'lucide-react'; -import type { GitStatus, GitFile, GitBranch as GitBranchType } from '../lib/tauri'; -import { DiffViewer } from './DiffViewer'; + Circle, +} from "lucide-react"; +import type { + GitStatus, + GitFile, + GitBranch as GitBranchType, +} from "../lib/tauri"; +import { type SshServerConfig, type SshConnectionInfo } from "../lib/tauri"; +import { FileExplorer } from "./FileExplorer"; +import { DiffViewer } from "./DiffViewer"; +import { SshPanel, LinuxIcon } from "./ssh/SshPanel"; + +// ── Types ────────────────────────────────────────────────────────────────────── interface GitPanelProps { status: GitStatus | null; @@ -37,32 +42,69 @@ interface GitPanelProps { onCommit: (message: string) => void; onCheckoutBranch: (name: string) => void; onCreateBranch: (name: string) => void; + sshServers?: SshServerConfig[]; + sshConnections?: SshConnectionInfo[]; + sshConnecting?: string | null; + sshError?: string | null; + onSshSaveServer?: (config: SshServerConfig) => void; + onSshDeleteServer?: (id: string) => void; + onSshConnect?: (configId: string) => void; + onSshDisconnect?: (configId: string) => void; + onSshSpawnTerminal?: (configId: string, serverName: string) => void; + isOpen?: boolean; + onToggle?: (open: boolean) => void; } -// Minimalist status icons -const statusIcons: Record = { - M: , - A: , - D: , - R: , - '?': , -}; - -// Smooth UI transitions -const fastTransition = { duration: 0.15 }; -const springTransition = { type: 'spring', bounce: 0, duration: 0.3 } as const; - -type DockTab = 'explorer' | 'todo' | 'git' | 'terminal'; - -const dockItems: { id: DockTab; icon: React.ReactNode; label: string }[] = [ - // Updated to IntelliJ-style Folder - { id: 'explorer', icon: , label: 'File Tree' }, - { id: 'todo', icon: , label: 'Todo List' }, - { id: 'git', icon: , label: 'Source Control' }, - // Updated to IntelliJ-style Terminal Monitor - { id: 'terminal', icon: , label: 'Terminal' }, +type DockTab = "explorer" | "todo" | "git" | "terminal" | "ssh"; +type SshAuthMethod = + | { type: "password"; password: string } + | { type: "key"; path: string; passphrase?: string }; + +// ── Constants ────────────────────────────────────────────────────────────────── + +const ease = [0.32, 0.72, 0, 1] as const; +const spring = { type: "spring", stiffness: 520, damping: 38 } as const; + +// macOS-style font stack +const fontStack = + '-apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif'; + +const dockItems: { + id: DockTab; + icon: React.ReactNode; + label: string; + separator?: boolean; +}[] = [ + { + id: "explorer", + icon: , + label: "Files", + }, + { + id: "todo", + icon: , + label: "Tasks", + }, + { + id: "git", + icon: , + label: "Source Control", + }, + { + id: "terminal", + icon: , + label: "Terminal", + }, + { + id: "ssh", + icon: , + label: "Remote", + separator: true, + }, ]; +// ── File Row ─────────────────────────────────────────────────────────────────── + function FileRow({ file, isStaged, @@ -75,19 +117,19 @@ function FileRow({ const [expanded, setExpanded] = useState(false); const [loadingDiff, setLoadingDiff] = useState(false); const [diff, setDiff] = useState(null); + const [hovered, setHovered] = useState(false); const handleClick = async () => { if (!expanded && !diff) { setLoadingDiff(true); try { - // Safe dynamic import for Tauri - const { invoke } = await import('@tauri-apps/api/core'); - const result = await invoke<{ diff: string }>('get_git_diff', { + const { invoke } = await import("@tauri-apps/api/core"); + const result = await invoke<{ diff: string }>("get_git_diff", { filePath: file.path, }); setDiff(result.diff); } catch (e) { - console.error('Failed to load diff:', e); + console.error("Failed to load diff:", e); } finally { setLoadingDiff(false); } @@ -95,74 +137,110 @@ function FileRow({ setExpanded(!expanded); }; - const fileName = file.path.split('/').pop() || file.path; - + const fileName = file.path.split("/").pop() || file.path; + const folder = file.path.includes("/") + ? file.path.substring(0, file.path.lastIndexOf("/")) + : ""; + return (
setHovered(true)} + onMouseLeave={() => setHovered(false)} + className="flex items-center gap-[6px] px-3 h-[26px] hover:bg-white/[0.025] cursor-default transition-colors duration-100 group" > - + -
- + {/* Status dot */} + + +
+ {fileName} + {folder && ( + + {folder} + + )}
- +
{expanded && ( -
-
+
+
{loadingDiff ? ( -
- - Fetching diff... +
+ + Fetching diff…
) : diff ? ( -
- -
+ ) : ( -
+
No displayable changes
)} - - {/* Visual detail from screenshot */} -
@@ -172,6 +250,8 @@ function FileRow({ ); } +// ── Commit Form ──────────────────────────────────────────────────────────────── + function CommitForm({ onCommit, onStageAll, @@ -181,95 +261,280 @@ function CommitForm({ onStageAll: () => void; disabled: boolean; }) { - const [message, setMessage] = useState(''); + const [message, setMessage] = useState(""); const [committing, setCommitting] = useState(false); + const [focused, setFocused] = useState(false); const handleCommit = async () => { if (!message.trim() || committing) return; setCommitting(true); try { await onCommit(message.trim()); - setMessage(''); + setMessage(""); } catch (e) { - console.error('Commit failed:', e); + console.error("Commit failed:", e); } finally { setCommitting(false); } }; return ( -
- {/* Revert / Stage Actions */} -
- - +
- {/* Commit Input */} -
-
- +
+
+
setMessage(e.target.value)} - placeholder="Write a commit message ." + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + placeholder="Commit message" disabled={disabled || committing} - className="flex-1 bg-transparent text-[12px] text-neutral-200 placeholder-neutral-600 outline-none h-full" + className="flex-1 bg-transparent text-[12px] text-white/85 placeholder-white/30 outline-none h-full" + style={{ fontFamily: fontStack }} onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey) { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleCommit(); } }} />
- + {committing ? ( + + ) : ( + + )} + Commit +
); } -function TabPlaceholder({ icon, title }: { icon: React.ReactNode; title: string }) { +// ── Tab Placeholder ──────────────────────────────────────────────────────────── + +function TabPlaceholder({ + icon, + title, +}: { + icon: React.ReactNode; + title: string; +}) { return ( -
+
{icon}
-

{title}

-

- This module is actively being developed. +

+ {title} +

+

+ This module is under active development.

); } +// ── Input Helper ─────────────────────────────────────────────────────────────── + +function FormInput({ + label, + optional, + ...props +}: React.InputHTMLAttributes & { + label: string; + optional?: boolean; +}) { + const [focused, setFocused] = useState(false); + return ( +
+ + setFocused(true)} + onBlur={() => setFocused(false)} + className="w-full h-[30px] rounded-[5px] px-2.5 text-[12px] text-white/90 placeholder-white/25 outline-none transition-all" + style={{ + background: focused + ? "rgba(255,255,255,0.04)" + : "rgba(255,255,255,0.025)", + border: focused + ? "1px solid rgba(59,130,246,0.4)" + : "1px solid rgba(255,255,255,0.06)", + boxShadow: focused ? "0 0 0 3px rgba(59,130,246,0.08)" : "none", + fontFamily: fontStack, + }} + /> +
+ ); +} + +// ── Dock Button ──────────────────────────────────────────────────────────────── + +function DockButton({ + item, + isActive, + onClick, + hasIndicator, +}: { + item: (typeof dockItems)[number]; + isActive: boolean; + onClick: () => void; + hasIndicator?: boolean; +}) { + const isSsh = item.id === "ssh"; + const [hovered, setHovered] = useState(false); + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + title={item.label} + whileTap={{ scale: 0.88 }} + transition={{ duration: 0.1 }} + className={`relative w-[28px] h-[28px] flex items-center justify-center rounded-[6px] transition-colors duration-150 ${ + isActive + ? "text-white bg-[#171717]" + : "text-[#666] hover:text-[#ededed] hover:bg-[#0e0e0e]" + }`} + > + {/* Active background */} + {isActive && ( + + )} + + {/* Hover glow (subtle) */} + {!isActive && hovered && ( + + )} + + {/* Active left rail */} + {isActive && ( + + )} + + + {item.icon} + + + {/* Indicator dot */} + {hasIndicator && ( + + )} + + ); +} + +// ── Git Panel ────────────────────────────────────────────────────────────────── + export const GitPanel = memo(function GitPanel({ status, branches, @@ -280,110 +545,188 @@ export const GitPanel = memo(function GitPanel({ onUnstageFile, onStageAll, onCommit, - onCheckoutBranch, + sshServers = [], + sshConnections = [], + sshConnecting, + sshError, + onSshSaveServer, + onSshDeleteServer, + onSshConnect, + onSshDisconnect, + onSshSpawnTerminal, + isOpen = true, + onToggle, }: GitPanelProps) { - const [activeDockTab, setActiveDockTab] = useState('git'); - const [activeGitTab, setActiveGitTab] = useState<'unstaged' | 'staged'>('unstaged'); + const [activeDockTab, setActiveDockTab] = useState("git"); + const [activeGitTab, setActiveGitTab] = useState<"unstaged" | "staged">( + "unstaged", + ); - const unstagedChanges = status ? status.modified.length + status.untracked.length : 0; + const unstagedChanges = status + ? status.modified.length + status.untracked.length + : 0; const stagedChanges = status ? status.staged.length : 0; const totalChanges = unstagedChanges + stagedChanges; return ( -
- {/* ── Main Left Panel Content ── */} -
- - {activeDockTab === 'git' ? ( +
+ {/* ── Main Panel ── */} +
+ {activeDockTab === "git" ? ( !status ? ( -
- -

No Git Repository

-

- Initialize git in your workspace to enable source control features. +

+
+ +
+

+ No Git repository +

+

+ Initialize git in your workspace to enable source control.

) : ( <> {/* Header */} -
-
- +
+
+ + +
-
- - {currentBranch || 'master'} -
+ + + + {currentBranch || "main"} + +
- {/* Segmented Control / Tabs */} -
-
- - - + {/* Segmented tabs */} +
+
+ {(["unstaged", "staged"] as const).map((tab) => { + const count = + tab === "unstaged" ? unstagedChanges : stagedChanges; + return ( + + ); + })}
- {/* Content List */} -
+ {/* Content */} +
- {activeGitTab === 'unstaged' ? ( -
- {(status.modified.length > 0 || status.untracked.length > 0) ? ( + {activeGitTab === "unstaged" ? ( +
+ {status.modified.length > 0 || + status.untracked.length > 0 ? ( <> {status.modified.map((file) => ( ) : ( -
-

+

+

No unstaged changes

+

+ Your working tree is clean +

)}
) : ( -
+
{status.staged.length > 0 ? ( status.staged.map((file) => ( )) ) : ( -
-

+

+

No staged changes

+

+ Stage files to commit +

)}
)} - {/* Screenshot Detail: Recent Commits Section */} - {activeGitTab === 'unstaged' && ( -
-

- Recent Commits -

-
-
- -
-
-

Initial commit from Clarrk

-
- R1x1604 - 11 ago + {activeGitTab === "unstaged" && ( +
+
+

+ Recent commit +

+
+
+ +
+
+

+ Initial commit +

+
+ + a1b2c3d + + + 2h ago + +
@@ -455,7 +817,7 @@ export const GitPanel = memo(function GitPanel({
- {/* Commit Form Block */} + {/* Commit Form */}
) - ) : activeDockTab === 'explorer' ? ( - } title="IDE File Explorer" /> - ) : activeDockTab === 'todo' ? ( - } title="Project Tasks" /> - ) : activeDockTab === 'terminal' ? ( - } title="Use main terminal" /> + ) : activeDockTab === "ssh" ? ( + + ) : activeDockTab === "explorer" ? ( + + ) : activeDockTab === "todo" ? ( + } + title="Project Tasks" + /> ) : ( - } title="Terminal & Console" /> + } + title="Use main terminal" + /> )}
{/* ── Right Navigation Dock ── */} -
+
{dockItems.map((item) => ( - +
+ {item.separator && ( +
+ )} + { + if (activeDockTab === item.id && isOpen) { + onToggle?.(false); + } else { + setActiveDockTab(item.id); + onToggle?.(true); + } + }} + hasIndicator={ + item.id === "ssh" && + sshConnections.length > 0 && + (activeDockTab !== item.id || !isOpen) + } + /> +
))}
); -}); \ No newline at end of file +}); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index ff9d8a1..36a44c3 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,20 +1,42 @@ -import React, { ReactNode, useEffect, useState } from 'react'; -import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; +import React, { ReactNode, useEffect, useState } from "react"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; type SectionKey = - | 'appearance' | 'chat' | 'notifications' | 'sessions' - | 'shortcuts' | 'git' | 'projects' | 'agents' - | 'commands' | 'mcp' | 'providers' | 'usage' - | 'skills' | 'voice' | 'privacy'; - -type PermissionState = 'allow' | 'deny' | 'ask'; + | "appearance" + | "chat" + | "notifications" + | "sessions" + | "shortcuts" + | "git" + | "projects" + | "agents" + | "commands" + | "mcp" + | "providers" + | "usage" + | "skills" + | "voice" + | "privacy"; + +type PermissionState = "allow" | "deny" | "ask"; type ToggleKey = - | 'showReasoning' | 'stickyHeader' | 'showToolIcons' | 'showDotfiles' - | 'queueMessages' | 'persistDrafts' | 'spellcheck' | 'desktopAlerts' - | 'mentionAlerts' | 'soundCues' | 'usageTelemetry' | 'localHistory' - | 'terminalQuickKeys' | 'sendUsageReports' - | 'gitmojiPicker' | 'showGitignored'; + | "showReasoning" + | "stickyHeader" + | "showToolIcons" + | "showDotfiles" + | "queueMessages" + | "persistDrafts" + | "spellcheck" + | "desktopAlerts" + | "mentionAlerts" + | "soundCues" + | "usageTelemetry" + | "localHistory" + | "terminalQuickKeys" + | "sendUsageReports" + | "gitmojiPicker" + | "showGitignored"; type ToggleState = Record; @@ -50,229 +72,514 @@ type SettingsState = { }; function cx(...parts: Array) { - return parts.filter(Boolean).join(' '); + return parts.filter(Boolean).join(" "); } // ─── Icons ──────────────────────────────────────────────────────────────────── const I = { Appearance: (p: any) => ( - - + + + ), Chat: (p: any) => ( - - + + ), Bell: (p: any) => ( - - + + ), Clock: (p: any) => ( - - + + + ), Keyboard: (p: any) => ( - - + + + ), Git: (p: any) => ( - - + + + + ), Folder: (p: any) => ( - - + + ), Agent: (p: any) => ( - - + + + ), Command: (p: any) => ( - - + + ), Plug: (p: any) => ( - - + + ), Provider: (p: any) => ( - - + + + ), BarChart: (p: any) => ( - - + + ), Star: (p: any) => ( - - + + ), Mic: (p: any) => ( - - + + + ), Globe: (p: any) => ( - - + + + ), X: (p: any) => ( - - + + ), ChevronDown: (p: any) => ( - - + + ), Plus: (p: any) => ( - - + + ), RotateCcw: (p: any) => ( - - + + + ), Info: (p: any) => ( - - + + + ), Check: (p: any) => ( - - + + ), Shield: (p: any) => ( - - + + ), Trash: (p: any) => ( - - + + + + + ), Github: (p: any) => ( - + ), User: (p: any) => ( - - + + + ), Key: (p: any) => ( - - + + + ), Link2: (p: any) => ( - - + + + ), Unlink: (p: any) => ( - - + + + + ), }; // ─── Nav ────────────────────────────────────────────────────────────────────── -const NAV: { key: SectionKey; label: string; icon: keyof typeof I; beta?: boolean }[] = [ - { key: 'appearance', label: 'Appearance', icon: 'Appearance' }, - { key: 'chat', label: 'Chat', icon: 'Chat' }, - { key: 'notifications', label: 'Notifications', icon: 'Bell' }, - { key: 'sessions', label: 'Sessions', icon: 'Clock' }, - { key: 'shortcuts', label: 'Shortcuts', icon: 'Keyboard' }, - { key: 'git', label: 'Git', icon: 'Git' }, - { key: 'projects', label: 'Projects', icon: 'Folder' }, - { key: 'agents', label: 'Agents', icon: 'Agent' }, - { key: 'commands', label: 'Commands', icon: 'Command' }, - { key: 'mcp', label: 'MCP', icon: 'Plug' }, - { key: 'providers', label: 'Providers', icon: 'Provider' }, - { key: 'usage', label: 'Usage', icon: 'BarChart' }, - { key: 'skills', label: 'Skills', icon: 'Star' }, - { key: 'voice', label: 'Voice', icon: 'Mic', beta: true }, - { key: 'privacy', label: 'Remote Tunnel', icon: 'Globe', beta: true }, +const NAV: { + key: SectionKey; + label: string; + icon: keyof typeof I; + beta?: boolean; +}[] = [ + { key: "appearance", label: "Appearance", icon: "Appearance" }, + { key: "chat", label: "Chat", icon: "Chat" }, + { key: "notifications", label: "Notifications", icon: "Bell" }, + { key: "sessions", label: "Sessions", icon: "Clock" }, + { key: "shortcuts", label: "Shortcuts", icon: "Keyboard" }, + { key: "git", label: "Git", icon: "Git" }, + { key: "projects", label: "Projects", icon: "Folder" }, + { key: "agents", label: "Agents", icon: "Agent" }, + { key: "commands", label: "Commands", icon: "Command" }, + { key: "mcp", label: "MCP", icon: "Plug" }, + { key: "providers", label: "Providers", icon: "Provider" }, + { key: "usage", label: "Usage", icon: "BarChart" }, + { key: "skills", label: "Skills", icon: "Star" }, + { key: "voice", label: "Voice", icon: "Mic", beta: true }, + { key: "privacy", label: "Remote Tunnel", icon: "Globe", beta: true }, ]; // ─── Data ───────────────────────────────────────────────────────────────────── const AGENTS = [ - { id: 'build', name: 'build', kind: 'system', desc: 'The default agent. Executes tools and edits.' }, - { id: 'explore', name: 'explore', kind: 'system', desc: 'Fast agent specialized for exploration.' }, - { id: 'general', name: 'general', kind: 'system', desc: 'General-purpose agent for mixed tasks.' }, - { id: 'plan', name: 'plan', kind: 'system', desc: 'Read-only planning. No edits or commands.' }, + { + id: "build", + name: "build", + kind: "system", + desc: "The default agent. Executes tools and edits.", + }, + { + id: "explore", + name: "explore", + kind: "system", + desc: "Fast agent specialized for exploration.", + }, + { + id: "general", + name: "general", + kind: "system", + desc: "General-purpose agent for mixed tasks.", + }, + { + id: "plan", + name: "plan", + kind: "system", + desc: "Read-only planning. No edits or commands.", + }, ]; -const TOOL_ROWS: { label: string; key: string; mono: string; meta?: string }[] = [ - { label: 'Default', key: '*', mono: '*' }, - { label: 'Apply Patch', key: 'apply_patch', mono: 'apply_patch' }, - { label: 'Bash', key: 'bash', mono: 'bash' }, - { label: 'CodeSearch', key: 'codesearch', mono: 'codesearch' }, - { label: 'Doom Loop', key: 'doom_loop', mono: 'doom_loop' }, - { label: 'Edit', key: 'edit', mono: 'edit' }, - { label: 'External Directory', key: 'external_directory', mono: 'external_directory', meta: 'Global: ask Β· Rules: 1 allow' }, - { label: 'Glob', key: 'glob', mono: 'glob' }, - { label: 'Grep', key: 'grep', mono: 'grep' }, - { label: 'List', key: 'list', mono: 'list' }, - { label: 'Lsp', key: 'lsp', mono: 'lsp' }, - { label: 'Plan Enter', key: 'plan_enter', mono: 'plan_enter' }, - { label: 'Plan Exit', key: 'plan_exit', mono: 'plan_exit' }, -]; +const TOOL_ROWS: { label: string; key: string; mono: string; meta?: string }[] = + [ + { label: "Default", key: "*", mono: "*" }, + { label: "Apply Patch", key: "apply_patch", mono: "apply_patch" }, + { label: "Bash", key: "bash", mono: "bash" }, + { label: "CodeSearch", key: "codesearch", mono: "codesearch" }, + { label: "Doom Loop", key: "doom_loop", mono: "doom_loop" }, + { label: "Edit", key: "edit", mono: "edit" }, + { + label: "External Directory", + key: "external_directory", + mono: "external_directory", + meta: "Global: ask Β· Rules: 1 allow", + }, + { label: "Glob", key: "glob", mono: "glob" }, + { label: "Grep", key: "grep", mono: "grep" }, + { label: "List", key: "list", mono: "list" }, + { label: "Lsp", key: "lsp", mono: "lsp" }, + { label: "Plan Enter", key: "plan_enter", mono: "plan_enter" }, + { label: "Plan Exit", key: "plan_exit", mono: "plan_exit" }, + ]; function createDefault(): SettingsState { return { - selectedAgent: 'explore', - colorMode: 'system', - lightTheme: 'Flexoki', - darkTheme: 'Flexoki', - appName: 'OpenChamber – AI Coding Assistant', - renderMode: 'live', - messageRendering: 'markdown', - mermaidRendering: 'svg', - diffLayout: 'inline', - diffViewMode: 'all-files', - defaultModel: 'MiniMax M2.5', + selectedAgent: "explore", + colorMode: "system", + lightTheme: "Flexoki", + darkTheme: "Flexoki", + appName: "OpenChamber – AI Coding Assistant", + renderMode: "live", + messageRendering: "markdown", + mermaidRendering: "svg", + diffLayout: "inline", + diffViewMode: "all-files", + defaultModel: "MiniMax M2.5", interfaceFontSize: 100, terminalFontSize: 13, spacingDensity: 100, inputBarOffset: 0, toggles: { - showReasoning: false, stickyHeader: true, showToolIcons: true, - showDotfiles: true, queueMessages: true, persistDrafts: true, - spellcheck: false, desktopAlerts: true, mentionAlerts: true, - soundCues: false, usageTelemetry: false, localHistory: true, - terminalQuickKeys: false, sendUsageReports: true, - gitmojiPicker: false, showGitignored: false, + showReasoning: false, + stickyHeader: true, + showToolIcons: true, + showDotfiles: true, + queueMessages: true, + persistDrafts: true, + spellcheck: false, + desktopAlerts: true, + mentionAlerts: true, + soundCues: false, + usageTelemetry: false, + localHistory: true, + terminalQuickKeys: false, + sendUsageReports: true, + gitmojiPicker: false, + showGitignored: false, }, permissions: { - '*': 'deny', apply_patch: 'deny', bash: 'allow', codesearch: 'allow', - doom_loop: 'ask', edit: 'deny', external_directory: 'ask', - glob: 'allow', grep: 'allow', list: 'allow', lsp: 'deny', - plan_enter: 'deny', plan_exit: 'deny', + "*": "deny", + apply_patch: "deny", + bash: "allow", + codesearch: "allow", + doom_loop: "ask", + edit: "deny", + external_directory: "ask", + glob: "allow", + grep: "allow", + list: "allow", + lsp: "deny", + plan_enter: "deny", + plan_exit: "deny", }, githubConnected: false, gitIdentities: [], @@ -283,7 +590,7 @@ function createDefault(): SettingsState { // ─── Primitives ─────────────────────────────────────────────────────────────── function Rule({ className }: { className?: string }) { - return
; + return
; } function SectionHeading({ children }: { children: ReactNode }) { @@ -294,17 +601,24 @@ function SectionHeading({ children }: { children: ReactNode }) { ); } -function PermTag({ state, onClick }: { state: PermissionState; onClick: () => void }) { +function PermTag({ + state, + onClick, +}: { + state: PermissionState; + onClick: () => void; +}) { const styles = { - allow: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/15 hover:bg-emerald-500/18', - deny: 'bg-red-500/10 text-red-400 border-red-500/15 hover:bg-red-500/18', - ask: 'bg-amber-500/10 text-amber-400 border-amber-500/15 hover:bg-amber-500/18', + allow: + "bg-emerald-500/10 text-emerald-400 border-emerald-500/15 hover:bg-emerald-500/18", + deny: "bg-red-500/10 text-red-400 border-red-500/15 hover:bg-red-500/18", + ask: "bg-amber-500/10 text-amber-400 border-amber-500/15 hover:bg-amber-500/18", } as const; return ( ); } -function RadioItem({ checked, label, onClick }: { checked: boolean; label: string; onClick: () => void }) { +function RadioItem({ + checked, + label, + onClick, +}: { + checked: boolean; + label: string; + onClick: () => void; +}) { return ( - ); } -function ModeChip({ value, active, onClick }: { value: string; active: boolean; onClick: () => void }) { +function ModeChip({ + value, + active, + onClick, +}: { + value: string; + active: boolean; + onClick: () => void; +}) { return ( @@ -458,22 +852,37 @@ function GitIdentityCard({ identity, onDelete }: { identity: GitIdentity; onDele {open && (
{[ - { label: 'Name', value: identity.name, mono: false }, - { label: 'Email', value: identity.email, mono: true }, + { label: "Name", value: identity.name, mono: false }, + { label: "Email", value: identity.email, mono: true }, ...(identity.signingKey - ? [{ label: 'Signing Key', value: identity.signingKey, mono: true }] + ? [ + { + label: "Signing Key", + value: identity.signingKey, + mono: true, + }, + ] : []), - ].map(row => ( + ].map((row) => (
-
{row.label}
-
{row.value}
+
+ {row.label} +
+
+ {row.value} +
))}
@@ -494,13 +903,21 @@ function GitIdentityCard({ identity, onDelete }: { identity: GitIdentity; onDele ); } -function NewIdentityForm({ onAdd, onCancel }: { +function NewIdentityForm({ + onAdd, + onCancel, +}: { onAdd: (id: GitIdentity) => void; onCancel: () => void; }) { - const [form, setForm] = useState({ name: '', email: '', signingKey: '' }); + const [form, setForm] = useState({ + name: "", + email: "", + signingKey: "", + }); const valid = form.name.trim().length > 0 && form.email.trim().length > 0; - const set = (k: keyof IdentityFormState) => (v: string) => setForm(p => ({ ...p, [k]: v })); + const set = (k: keyof IdentityFormState) => (v: string) => + setForm((p) => ({ ...p, [k]: v })); return ( -

New Identity

+

+ New Identity +

- {([ - { key: 'name', label: 'Name', placeholder: 'Jane Doe' }, - { key: 'email', label: 'Email', placeholder: 'jane@example.com' }, - { key: 'signingKey', label: 'Signing Key', placeholder: 'GPG key ID (optional)' }, - ] as { key: keyof IdentityFormState; label: string; placeholder: string }[]).map(f => ( + {( + [ + { key: "name", label: "Name", placeholder: "Jane Doe" }, + { key: "email", label: "Email", placeholder: "jane@example.com" }, + { + key: "signingKey", + label: "Signing Key", + placeholder: "GPG key ID (optional)", + }, + ] as { + key: keyof IdentityFormState; + label: string; + placeholder: string; + }[] + ).map((f) => (
- - + +
))}
@@ -542,10 +977,10 @@ function NewIdentityForm({ onAdd, onCancel }: { }} disabled={!valid} className={cx( - 'rounded-[6px] px-3.5 py-1.5 text-[12px] font-medium transition-all duration-150', + "rounded-[6px] px-3.5 py-1.5 text-[12px] font-medium transition-all duration-150", valid - ? 'bg-blue-500/18 text-blue-300 hover:bg-blue-500/28' - : 'cursor-not-allowed bg-white/[0.03] text-white/18', + ? "bg-blue-500/18 text-blue-300 hover:bg-blue-500/28" + : "cursor-not-allowed bg-white/[0.03] text-white/18", )} > Add Identity @@ -555,30 +990,40 @@ function NewIdentityForm({ onAdd, onCancel }: { ); } -function GitPanel({ s, update, toggle }: { s: SettingsState; update: any; toggle: any }) { +function GitPanel({ + s, + update, + toggle, +}: { + s: SettingsState; + update: any; + toggle: any; +}) { const [showNewForm, setShowNewForm] = useState(false); const handleConnect = () => { - update('githubConnected', true); - update('githubUser', { login: 'janedoe', name: 'Jane Doe', avatar: '' }); + update("githubConnected", true); + update("githubUser", { login: "janedoe", name: "Jane Doe", avatar: "" }); }; const handleDisconnect = () => { - update('githubConnected', false); - update('githubUser', undefined); + update("githubConnected", false); + update("githubUser", undefined); }; const addIdentity = (id: GitIdentity) => { - update('gitIdentities', [...s.gitIdentities, id]); + update("gitIdentities", [...s.gitIdentities, id]); setShowNewForm(false); }; const removeIdentity = (id: string) => - update('gitIdentities', s.gitIdentities.filter(i => i.id !== id)); + update( + "gitIdentities", + s.gitIdentities.filter((i) => i.id !== id), + ); return (
- {/* ── GitHub ── */}
@@ -586,7 +1031,8 @@ function GitPanel({ s, update, toggle }: { s: SettingsState; update: any; toggle

- Connect your GitHub account to enable pull request workflows and repository access. + Connect your GitHub account to enable pull request workflows and + repository access.

@@ -605,7 +1051,9 @@ function GitPanel({ s, update, toggle }: { s: SettingsState; update: any; toggle
- Not connected + + Not connected +
@@ -670,20 +1118,25 @@ function GitPanel({ s, update, toggle }: { s: SettingsState; update: any; toggle {/* ── Identities ── */}
-

Identities

+

+ Identities +

@@ -693,7 +1146,10 @@ function GitPanel({ s, update, toggle }: { s: SettingsState; update: any; toggle {showNewForm && ( - setShowNewForm(false)} /> + setShowNewForm(false)} + /> )} @@ -711,7 +1167,9 @@ function GitPanel({ s, update, toggle }: { s: SettingsState; update: any; toggle

-

No identities configured

+

+ No identities configured +

Create one to manage Git author settings per project

@@ -726,7 +1184,7 @@ function GitPanel({ s, update, toggle }: { s: SettingsState; update: any; toggle ) : ( - {s.gitIdentities.map(identity => ( + {s.gitIdentities.map((identity) => ( - removeIdentity(identity.id)} /> + removeIdentity(identity.id)} + /> ))} @@ -748,26 +1209,28 @@ function GitPanel({ s, update, toggle }: { s: SettingsState; update: any; toggle {/* ── Git Preferences ── */}
-

Preferences

+

+ Preferences +

Configure how Git integrates with your workflow.

toggle('gitmojiPicker')} + onChange={() => toggle("gitmojiPicker")} label="Enable Gitmoji Picker" desc="Show an emoji picker when composing commit messages." /> toggle('showGitignored')} + onChange={() => toggle("showGitignored")} label="Display Gitignored Files" desc="Include files excluded by .gitignore in the file tree." /> toggle('showDotfiles')} + onChange={() => toggle("showDotfiles")} label="Show Dotfiles" desc="Include hidden files starting with a dot." /> @@ -779,18 +1242,34 @@ function GitPanel({ s, update, toggle }: { s: SettingsState; update: any; toggle // ─── Other panels ───────────────────────────────────────────────────────────── -function AppearancePanel({ s, update, toggle }: { s: SettingsState; update: any; toggle: any }) { +function AppearancePanel({ + s, + update, + toggle, +}: { + s: SettingsState; + update: any; + toggle: any; +}) { return (
Color Mode
- {['system', 'light', 'dark'].map(m => ( - update('colorMode', m)} /> + {["system", "light", "dark"].map((m) => ( + update("colorMode", m)} + /> ))}
- {[['Light Theme', 'lightTheme'], ['Dark Theme', 'darkTheme']].map(([lbl, key]) => ( + {[ + ["Light Theme", "lightTheme"], + ["Dark Theme", "darkTheme"], + ].map(([lbl, key]) => (
{lbl} @@ -801,9 +1280,14 @@ function AppearancePanel({ s, update, toggle }: { s: SettingsState; update: any;
App Identity -

Install App Name β€” used by PWA installation process.

+

+ Install App Name β€” used by PWA installation process. +

- update('appName', v)} /> + update("appName", v)} + /> @@ -813,18 +1297,36 @@ function AppearancePanel({ s, update, toggle }: { s: SettingsState; update: any;
Spacing & Layout
- {([ - { label: 'Interface Font Size', key: 'interfaceFontSize' }, - { label: 'Terminal Font Size', key: 'terminalFontSize', min: 8, max: 32 }, - { label: 'Spacing Density', key: 'spacingDensity' }, - { label: 'Input Bar Offset', key: 'inputBarOffset', min: -100, max: 100, info: true }, - ] as any[]).map(({ label, key, min = 0, max = 999, info }) => ( + {( + [ + { label: "Interface Font Size", key: "interfaceFontSize" }, + { + label: "Terminal Font Size", + key: "terminalFontSize", + min: 8, + max: 32, + }, + { label: "Spacing Density", key: "spacingDensity" }, + { + label: "Input Bar Offset", + key: "inputBarOffset", + min: -100, + max: 100, + info: true, + }, + ] as any[] + ).map(({ label, key, min = 0, max = 999, info }) => (
{label} {info && }
- update(key, v)} min={min} max={max} /> + update(key, v)} + min={min} + max={max} + />
))}
@@ -832,14 +1334,18 @@ function AppearancePanel({ s, update, toggle }: { s: SettingsState; update: any;
Navigation - toggle('terminalQuickKeys')} label="Terminal Quick Keys" /> + toggle("terminalQuickKeys")} + label="Terminal Quick Keys" + />
Privacy toggle('sendUsageReports')} + onChange={() => toggle("sendUsageReports")} label="Send anonymous usage reports" desc="Only version, platform, and runtime are collected β€” no personal data or code." /> @@ -848,26 +1354,36 @@ function AppearancePanel({ s, update, toggle }: { s: SettingsState; update: any; ); } -function ChatPanel({ s, update, toggle }: { s: SettingsState; update: any; toggle: any }) { +function ChatPanel({ + s, + update, + toggle, +}: { + s: SettingsState; + update: any; + toggle: any; +}) { return (
Chat Render Mode
- {['sorted', 'live'].map(mode => ( + {["sorted", "live"].map((mode) => ( ); })} @@ -977,22 +1570,26 @@ function AgentsPanel({ s, update, cyclePermission }: { className="space-y-8 px-8 py-7" >
-

System Prompt

+

+ System Prompt +

-You are a file search specialist. You excel at thoroughly
-navigating and exploring codebases.
-
-Your strengths:
-- Rapidly finding files using glob patterns
-- Searching code and text with powerful regex patterns
-- Reading and analyzing file contents
+                  
+                    You are a file search specialist. You excel at thoroughly
+                    navigating and exploring codebases.
+                  
+                  Your strengths: - Rapidly finding files using glob patterns -
+                  Searching code and text with powerful regex patterns - Reading
+                  and analyzing file contents
                 
-

Tool Permissions

+

+ Tool Permissions +

@@ -1002,16 +1599,28 @@ Your strengths:
- {row.label} - {row.mono} - {row.meta && {row.meta}} + + {row.label} + + + {row.mono} + + {row.meta && ( + + {row.meta} + + )}
- cyclePermission(row.key)} /> + cyclePermission(row.key)} + />
))}
@@ -1028,9 +1637,21 @@ function NotificationsPanel({ s, toggle }: { s: SettingsState; toggle: any }) {
Alerts
- toggle('desktopAlerts')} label="Desktop notifications" /> - toggle('mentionAlerts')} label="Mention alerts" /> - toggle('soundCues')} label="Sound cues" /> + toggle("desktopAlerts")} + label="Desktop notifications" + /> + toggle("mentionAlerts")} + label="Mention alerts" + /> + toggle("soundCues")} + label="Sound cues" + />
); @@ -1041,8 +1662,16 @@ function SessionsPanel({ s, toggle }: { s: SettingsState; toggle: any }) {
History
- toggle('persistDrafts')} label="Persist draft messages" /> - toggle('localHistory')} label="Store local history" /> + toggle("persistDrafts")} + label="Persist draft messages" + /> + toggle("localHistory")} + label="Store local history" + />
); @@ -1063,42 +1692,68 @@ function PlaceholderPanel({ label }: { label: string }) { export function SettingsModal({ onClose }: { onClose: () => void }) { const prefersReduced = useReducedMotion(); - const [active, setActive] = useState('git'); + const [active, setActive] = useState("git"); const [s, setS] = useState(createDefault); - const update = (key: string, val: any) => setS(prev => ({ ...prev, [key]: val })); + const update = (key: string, val: any) => + setS((prev) => ({ ...prev, [key]: val })); const toggle = (key: ToggleKey) => - setS(prev => ({ ...prev, toggles: { ...prev.toggles, [key]: !prev.toggles[key] } })); + setS((prev) => ({ + ...prev, + toggles: { ...prev.toggles, [key]: !prev.toggles[key] }, + })); const cyclePermission = (key: string) => { - const order: PermissionState[] = ['deny', 'ask', 'allow']; - setS(prev => { - const cur = prev.permissions[key] ?? 'deny'; + const order: PermissionState[] = ["deny", "ask", "allow"]; + setS((prev) => { + const cur = prev.permissions[key] ?? "deny"; const next = order[(order.indexOf(cur) + 1) % order.length]; return { ...prev, permissions: { ...prev.permissions, [key]: next } }; }); }; useEffect(() => { - const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; const prev = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - window.addEventListener('keydown', handler); - return () => { document.body.style.overflow = prev; window.removeEventListener('keydown', handler); }; + document.body.style.overflow = "hidden"; + window.addEventListener("keydown", handler); + return () => { + document.body.style.overflow = prev; + window.removeEventListener("keydown", handler); + }; }, [onClose]); - const isAgents = active === 'agents'; + const isAgents = active === "agents"; const panel = (() => { switch (active) { - case 'appearance': return ; - case 'chat': return ; - case 'agents': return ; - case 'notifications': return ; - case 'sessions': return ; - case 'git': return ; - default: return n.key === active)?.label ?? active} />; + case "appearance": + return ; + case "chat": + return ; + case "agents": + return ( + + ); + case "notifications": + return ; + case "sessions": + return ; + case "git": + return ; + default: + return ( + n.key === active)?.label ?? active} + /> + ); } })(); @@ -1114,11 +1769,21 @@ export function SettingsModal({ onClose }: { onClose: () => void }) { e.stopPropagation()} + initial={ + prefersReduced ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: 14 } + } + animate={ + prefersReduced ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 } + } + exit={ + prefersReduced ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: 8 } + } + transition={ + prefersReduced + ? { duration: 0.14 } + : { type: "spring", stiffness: 340, damping: 28, mass: 0.85 } + } + onClick={(e) => e.stopPropagation()} className="flex h-[min(92vh,840px)] w-full max-w-[1060px] overflow-hidden rounded-[14px] border border-white/[0.07] bg-[#0d0d0d] shadow-[0_48px_120px_rgba(0,0,0,0.95)]" > {/* ── Sidebar ── */} @@ -1132,15 +1797,21 @@ export function SettingsModal({ onClose }: { onClose: () => void }) { key={key} onClick={() => setActive(key)} className={cx( - 'relative mx-1.5 flex w-[calc(100%-12px)] items-center gap-3 rounded-[7px] px-3 py-[8px] text-left text-[13px] transition-colors duration-100', - isActive ? 'text-white/88' : 'text-white/32 hover:text-white/60', + "relative mx-1.5 flex w-[calc(100%-12px)] items-center gap-3 rounded-[7px] px-3 py-[8px] text-left text-[13px] transition-colors duration-100", + isActive + ? "text-white/88" + : "text-white/32 hover:text-white/60", )} > {isActive && ( )} @@ -1166,9 +1837,14 @@ export function SettingsModal({ onClose }: { onClose: () => void }) { {/* Topbar */}
- {(() => { const n = NAV.find(n => n.key === active); if (!n) return null; const Icon = I[n.icon]; return ; })()} + {(() => { + const n = NAV.find((n) => n.key === active); + if (!n) return null; + const Icon = I[n.icon]; + return ; + })()} - {NAV.find(n => n.key === active)?.label} + {NAV.find((n) => n.key === active)?.label}
{/* Panel */} -
+
{panel} @@ -1200,4 +1881,4 @@ export function SettingsModal({ onClose }: { onClose: () => void }) { ); } -export default SettingsModal; \ No newline at end of file +export default SettingsModal; diff --git a/src/components/TerminalPanel.tsx b/src/components/TerminalPanel.tsx index 7d35b16..79afe5a 100644 --- a/src/components/TerminalPanel.tsx +++ b/src/components/TerminalPanel.tsx @@ -22,15 +22,27 @@ function TerminalView({ term, active }: { term: TerminalInstance; active: boolea if (!ref.current || ref.current.clientWidth === 0) return; try { term.fitAddon.fit(); - invoke('resize_terminal', { - id: term.id, - cols: term.terminal.cols, - rows: term.terminal.rows, - }).catch(() => {}); + const cols = term.terminal.cols; + const rows = term.terminal.rows; + + if (term.id.startsWith('ssh-shell-')) { + invoke('ssh_resize_shell', { + shellId: term.id, + cols, + rows + }).catch(() => {}); + } else { + invoke('resize_terminal', { + id: term.id, + cols, + rows, + }).catch(() => {}); + } } catch {} }; doResize(); + term.terminal.focus(); const resizeObserver = new ResizeObserver(() => { if (resizeTimeout.current) clearTimeout(resizeTimeout.current); @@ -57,6 +69,17 @@ function TerminalView({ term, active }: { term: TerminalInstance; active: boolea export function TerminalPanel({ onClose, isOpen }: { onClose?: () => void; isOpen: boolean }) { const { terminals, activeTerminalId, createTerminal, closeTerminal, setActiveTerminal } = useTerminal(); + useEffect(() => { + const handleSshTerminal = (e: Event) => { + const customEvent = e as CustomEvent<{ configId: string, serverName: string }>; + const { configId, serverName } = customEvent.detail; + createTerminal({ type: 'ssh', configId, serverName }); + }; + + window.addEventListener('spawn-ssh-terminal', handleSshTerminal); + return () => window.removeEventListener('spawn-ssh-terminal', handleSshTerminal); + }, [createTerminal]); + useEffect(() => { if (isOpen && terminals.length === 0) { createTerminal(); @@ -84,7 +107,7 @@ export function TerminalPanel({ onClose, isOpen }: { onClose?: () => void; isOpe }`} > - {t.id} + {t.label || t.id} + )} +
+ + {/* Right side: Tool actions */} +
+ + + + +
+ + +
+ +
+ + Commit + +
+ +
+ + + + + +
+
+ +{additions} + -{deletions} +
+ +
+
+
+ + setIsPickerOpen(false)} + initialPath={projectDir} + sshConnections={sshConnections} + onSelect={(path, configId) => { + console.log('Selected path:', path, 'on config:', configId); + }} + /> + + ); +} diff --git a/src/components/ssh/OsIcons.tsx b/src/components/ssh/OsIcons.tsx new file mode 100644 index 0000000..7600db2 --- /dev/null +++ b/src/components/ssh/OsIcons.tsx @@ -0,0 +1,285 @@ +import { useState, useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; + +export type OsType = + | 'linux' + | 'ubuntu' + | 'debian' + | 'raspberrypi' + | 'fedora' + | 'redhat' + | 'nixos' + | 'gentoo' + | 'freebsd' + | 'arch' + | 'alpine' + | 'mint' + | 'kali' + | 'macos' + | 'windows'; + +type IconProps = { size?: number; className?: string }; + +/* ── Icon set (Simple Icons, currentColor) ─────────────────────────────── */ + +const TuxIcon = ({ size = 16, className = '' }: IconProps) => ( + + + +); + +const UbuntuIcon = ({ size = 16, className = '' }: IconProps) => ( + + + +); + +const DebianIcon = ({ size = 16, className = '' }: IconProps) => ( + + + +); + +const RaspberryPiIcon = ({ size = 16, className = '' }: IconProps) => ( + + + +); + +const ArchIcon = ({ size = 16, className = '' }: IconProps) => ( + + + +); + +const AlpineIcon = ({ size = 16, className = '' }: IconProps) => ( + + + +); + +const MintIcon = ({ size = 16, className = '' }: IconProps) => ( + + + +); + +const KaliIcon = ({ size = 16, className = '' }: IconProps) => ( + + + +); + +const MacosIcon = ({ size = 16, className = '' }: IconProps) => ( + + + +); + +const WindowsIcon = ({ size = 16, className = '' }: IconProps) => ( + + + +); + +const FedoraIcon = ({ size = 16, className = '' }: IconProps) => ( + + + +); + +const RedHatIcon = ({ size = 16, className = '' }: IconProps) => ( + + + +); + +const NixOSIcon = ({ size = 16, className = '' }: IconProps) => ( + + + +); + +const GentooIcon = ({ size = 16, className = '' }: IconProps) => ( + + + +); + +const FreeBSDIcon = ({ size = 16, className = '' }: IconProps) => ( + + + +); + +/* ── Registry ──────────────────────────────────────────────────────────── */ + +const ICONS: Record JSX.Element> = { + linux: TuxIcon, + ubuntu: UbuntuIcon, + debian: DebianIcon, + raspberrypi: RaspberryPiIcon, + fedora: FedoraIcon, + redhat: RedHatIcon, + nixos: NixOSIcon, + gentoo: GentooIcon, + freebsd: FreeBSDIcon, + arch: ArchIcon, + alpine: AlpineIcon, + mint: MintIcon, + kali: KaliIcon, + macos: MacosIcon, + windows: WindowsIcon, +}; + +export const OS_OPTIONS: { id: OsType; label: string }[] = [ + { id: 'linux', label: 'Linux' }, + { id: 'ubuntu', label: 'Ubuntu' }, + { id: 'debian', label: 'Debian' }, + { id: 'raspberrypi', label: 'Raspberry Pi' }, + { id: 'fedora', label: 'Fedora' }, + { id: 'redhat', label: 'Red Hat' }, + { id: 'nixos', label: 'NixOS' }, + { id: 'gentoo', label: 'Gentoo' }, + { id: 'freebsd', label: 'FreeBSD' }, + { id: 'arch', label: 'Arch' }, + { id: 'alpine', label: 'Alpine' }, + { id: 'mint', label: 'Mint' }, + { id: 'kali', label: 'Kali' }, + { id: 'macos', label: 'macOS' }, + { id: 'windows', label: 'Windows' }, +]; + +const sans = '"Geist", "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif'; + +export function OsIcon({ + os, + size = 16, + className = '', +}: { + os?: OsType | string; + size?: number; + className?: string; +}) { + const key = (os && os in ICONS ? os : 'linux') as OsType; + const Cmp = ICONS[key]; + return ; +} + +/* ── Picker ────────────────────────────────────────────────────────────── */ + +export function OsPicker({ + value, + onChange, + size = 28, +}: { + value: OsType; + onChange: (os: OsType) => void; + size?: number; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const handle = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + document.addEventListener('mousedown', handle); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', handle); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + const current = OS_OPTIONS.find((o) => o.id === value) ?? OS_OPTIONS[0]; + + return ( +
+ setOpen((o) => !o)} + whileTap={{ scale: 0.94 }} + transition={{ duration: 0.08 }} + title={`OS: ${current.label} β€” click to change`} + className="flex items-center justify-center rounded-[6px] text-[#ededed] hover:text-white transition-colors" + style={{ + width: size, + height: size, + background: '#0a0a0a', + border: `1px solid ${open ? '#404040' : '#262626'}`, + }} + > + + + + + {open && ( + +
+ Operating system +
+
+ {OS_OPTIONS.map((opt) => { + const active = opt.id === value; + return ( + { + onChange(opt.id); + setOpen(false); + }} + whileTap={{ scale: 0.9 }} + transition={{ duration: 0.08 }} + title={opt.label} + className={`relative w-[34px] h-[34px] rounded-[5px] flex items-center justify-center transition-colors ${ + active + ? 'text-white bg-[#1f1f1f]' + : 'text-[#888] hover:text-[#ededed] hover:bg-[#171717]' + }`} + > + + {active && ( + + )} + + ); + })} +
+
+ {current.label} +
+
+ )} +
+
+ ); +} diff --git a/src/components/ssh/SshPanel.tsx b/src/components/ssh/SshPanel.tsx new file mode 100644 index 0000000..d210444 --- /dev/null +++ b/src/components/ssh/SshPanel.tsx @@ -0,0 +1,1375 @@ +import { useState, useMemo, useEffect, useRef } from 'react'; +import { motion, AnimatePresence, LayoutGroup } from 'motion/react'; +import { + Plus, + X, + Loader2, + Key, + Lock, + Pencil, + Trash2, + TerminalSquare, + FolderOpen, + Search, + ChevronDown, + Copy, + Check, + Tag as TagIcon, + ArrowUpRight, + MoreHorizontal, + CornerDownLeft, +} from 'lucide-react'; +import type { SshServerConfig, SshConnectionInfo, SshAuthMethod } from '../../lib/tauri'; +import { OsIcon, OsPicker, type OsType } from './OsIcons'; + +const easeOut = [0.16, 1, 0.3, 1] as const; +const easeStandard = [0.4, 0, 0.2, 1] as const; + +const sans = '"Geist", "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif'; +const mono = '"Geist Mono", "JetBrains Mono", "SF Mono", ui-monospace, Menlo, monospace'; + +/** + * LinuxIcon β€” kept as a thin alias to so any external + * imports (e.g. GitPanel's dock) keep working without changes. + */ +export const LinuxIcon = ({ className = '', size = 24 }: { className?: string; size?: number }) => ( + +); + +function uptimeStr(ms: number) { + const diff = Math.max(0, Date.now() - ms); + const s = Math.floor(diff / 1000); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${sec}s`; + return `${sec}s`; +} + +function StatusDot({ + state, + size = 6, +}: { + state: 'live' | 'connecting' | 'idle' | 'error'; + size?: number; +}) { + const colors = { + live: '#3aed8a', + connecting: '#f5a623', + idle: '#666', + error: '#ff4d4f', + }; + const c = colors[state]; + return ( + + + {state === 'live' && ( + + )} + + ); +} + +function CopyChip({ value, children }: { value: string; children: React.ReactNode }) { + const [copied, setCopied] = useState(false); + const handle = (e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard?.writeText(value).catch(() => {}); + setCopied(true); + setTimeout(() => setCopied(false), 1200); + }; + return ( + + ); +} + +function GhostButton({ + children, + onClick, + disabled, + active, + variant = 'default', + size = 'md', + className = '', + type = 'button', +}: { + children: React.ReactNode; + onClick?: (e: React.MouseEvent) => void; + disabled?: boolean; + active?: boolean; + variant?: 'default' | 'primary' | 'danger'; + size?: 'sm' | 'md'; + className?: string; + type?: 'button' | 'submit'; +}) { + const sizing = size === 'sm' ? 'h-[26px] px-2.5 text-[11.5px]' : 'h-[28px] px-3 text-[12px]'; + const styles = + variant === 'primary' + ? 'bg-white text-black hover:bg-[#ededed] border border-white' + : variant === 'danger' + ? 'bg-transparent text-[#a1a1a1] hover:bg-[#1a0a0c] hover:text-[#ff6b6b] border border-[#262626] hover:border-[#3a1a1d]' + : `bg-transparent ${active ? 'text-white border-[#404040]' : 'text-[#ededed] hover:text-white'} border border-[#262626] hover:border-[#404040] hover:bg-[#161616]`; + + return ( + + {children} + + ); +} + +function Chip({ + children, + tone = 'default', + className = '', +}: { + children: React.ReactNode; + tone?: 'default' | 'success' | 'warn'; + className?: string; +}) { + const tones = { + default: 'border-[#262626] text-[#a1a1a1] bg-transparent', + success: 'border-[#0f3a23] text-[#3aed8a] bg-[#0a1a13]', + warn: 'border-[#3a2a0f] text-[#f5a623] bg-[#1a1408]', + }; + return ( + + {children} + + ); +} + +function Input({ + label, + hint, + optional, + trailing, + ...props +}: React.InputHTMLAttributes & { + label?: string; + hint?: string; + optional?: boolean; + trailing?: React.ReactNode; +}) { + const [focused, setFocused] = useState(false); + return ( +
+ {label && ( +
+ + {hint && {hint}} +
+ )} +
+ { + setFocused(true); + props.onFocus?.(e); + }} + onBlur={(e) => { + setFocused(false); + props.onBlur?.(e); + }} + className="flex-1 min-w-0 bg-transparent px-2.5 text-[12.5px] text-white placeholder-[#525252] outline-none h-full" + style={{ fontFamily: props.type === 'password' || props.pattern ? mono : sans }} + /> + {trailing &&
{trailing}
} +
+
+ ); +} + +/* ── Trailing inline button used inside Input (e.g. file picker) ───────── */ +function TrailingIconButton({ + onClick, + title, + children, +}: { + onClick: () => void; + title: string; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +/** + * TagInput β€” Comma / Enter / Tab commits a chip, Backspace removes the last. + */ +function TagInput({ + label, + optional, + hint, + value, + onChange, + placeholder, +}: { + label?: string; + optional?: boolean; + hint?: string; + value: string[]; + onChange: (tags: string[]) => void; + placeholder?: string; +}) { + const [input, setInput] = useState(''); + const [focused, setFocused] = useState(false); + const inputRef = useRef(null); + + const commit = (raw: string) => { + const t = raw.trim().replace(/,/g, ''); + if (!t || value.includes(t)) return; + onChange([...value, t]); + }; + + const handleChange = (e: React.ChangeEvent) => { + const v = e.target.value; + if (v.includes(',')) { + const parts = v.split(','); + const last = parts.pop() ?? ''; + parts.forEach(commit); + setInput(last); + } else { + setInput(v); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (input.trim()) { + commit(input); + setInput(''); + } + } else if (e.key === 'Tab' && input.trim()) { + e.preventDefault(); + commit(input); + setInput(''); + } else if (e.key === 'Backspace' && !input && value.length > 0) { + e.preventDefault(); + onChange(value.slice(0, -1)); + } + }; + + const removeTag = (tag: string) => onChange(value.filter((t) => t !== tag)); + + return ( +
+ {label && ( +
+ + {hint && {hint}} +
+ )} +
inputRef.current?.focus()} + className="flex flex-wrap items-center gap-1 rounded-[6px] min-h-[32px] px-1.5 py-[3px] transition-colors cursor-text" + style={{ background: '#0a0a0a', border: `1px solid ${focused ? '#525252' : '#262626'}` }} + > + + {value.map((tag) => ( + + {tag} + + + ))} + + setFocused(true)} + onBlur={() => { + if (input.trim()) { + commit(input); + setInput(''); + } + setFocused(false); + }} + placeholder={value.length === 0 ? placeholder : ''} + className="flex-1 min-w-[60px] bg-transparent text-[12.5px] text-white placeholder-[#525252] outline-none h-[20px]" + style={{ fontFamily: sans }} + /> +
+
+ ); +} + +/* ── Tauri-aware file picker for SSH key path ──────────────────────────── */ +async function pickPrivateKeyFile(): Promise { + try { + const mod = await import('@tauri-apps/plugin-dialog'); + const selected = await mod.open({ + multiple: false, + directory: false, + title: 'Select SSH private key', + filters: [ + { name: 'SSH keys', extensions: ['pem', 'key', 'ppk', 'pub'] }, + { name: 'All files', extensions: ['*'] }, + ], + }); + if (typeof selected === 'string') return selected; + return null; + } catch (e) { + console.error('File picker unavailable:', e); + return null; + } +} + +async function pickDirectory(): Promise { + try { + const mod = await import('@tauri-apps/plugin-dialog'); + const selected = await mod.open({ + multiple: false, + directory: true, + title: 'Select working directory', + }); + if (typeof selected === 'string') return selected; + return null; + } catch (e) { + console.error('Directory picker unavailable:', e); + return null; + } +} + +function ServerForm({ + initial, + onSave, + onCancel, +}: { + initial?: SshServerConfig; + onSave: (config: SshServerConfig) => void; + onCancel: () => void; +}) { + const [name, setName] = useState(initial?.name ?? ''); + const [host, setHost] = useState(initial?.host ?? ''); + const [port, setPort] = useState(initial?.port ?? 22); + const [username, setUsername] = useState(initial?.username ?? ''); + const [authType, setAuthType] = useState<'password' | 'key'>( + initial?.auth_method?.type === 'key' ? 'key' : 'password' + ); + const [password, setPassword] = useState( + initial?.auth_method?.type === 'password' ? initial.auth_method.password : '' + ); + const [keyPath, setKeyPath] = useState( + initial?.auth_method?.type === 'key' ? initial.auth_method.path : '~/.ssh/id_ed25519' + ); + const [passphrase, setPassphrase] = useState( + initial?.auth_method?.type === 'key' ? (initial.auth_method.passphrase ?? '') : '' + ); + const [defaultDir, setDefaultDir] = useState(initial?.default_directory ?? ''); + const [group, setGroup] = useState(initial?.group ?? ''); + const [tags, setTags] = useState(initial?.tags ?? []); + const [os, setOs] = useState((initial?.os as OsType) ?? 'linux'); + + const isValid = name.trim() && host.trim() && username.trim(); + + const handleBrowseKey = async () => { + const picked = await pickPrivateKeyFile(); + if (picked) setKeyPath(picked); + }; + + const handleBrowseDir = async () => { + const picked = await pickDirectory(); + if (picked) setDefaultDir(picked); + }; + + const handleSave = () => { + if (!isValid) return; + const auth_method: SshAuthMethod = + authType === 'password' + ? { type: 'password', password } + : { type: 'key', path: keyPath, passphrase: passphrase || undefined }; + + onSave({ + id: initial?.id ?? `srv_${Date.now().toString(36)}`, + name: name.trim(), + host: host.trim(), + port, + username: username.trim(), + auth_method, + default_directory: defaultDir.trim() || undefined, + group: group.trim() || undefined, + tags, + os, + }); + }; + + return ( + +
+
+
+ {/* OS picker β€” click the icon to choose */} + + + {initial ? 'Edit connection' : 'New connection'} + + Β· + SSH +
+ +
+ +
+ setName(e.target.value)} placeholder="eu-prod-bastion" /> + +
+ setHost(e.target.value)} placeholder="bastion.example.com" /> + { + const raw = e.target.value.replace(/\D/g, ''); + setPort(raw ? Number(raw) : 22); + }} + /> +
+ + setUsername(e.target.value)} placeholder="deploy" /> + +
+ +
+ {(['key', 'password'] as const).map((type) => ( + + ))} +
+
+ + + + {authType === 'password' ? ( + setPassword(e.target.value)} + placeholder="β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’" + /> + ) : ( + <> + setKeyPath(e.target.value)} + placeholder="~/.ssh/id_ed25519" + trailing={ + + + + } + /> + setPassphrase(e.target.value)} + placeholder="β€’β€’β€’β€’β€’β€’β€’β€’" + /> + + )} + + + +
+ setGroup(e.target.value)} placeholder="Production" /> + +
+ + setDefaultDir(e.target.value)} + placeholder="/srv/app" + trailing={ + + + + } + /> +
+ +
+ + + Press Enter to save + +
+ Cancel + + {initial ? 'Save changes' : 'Create'} + +
+
+
+
+ ); +} + +function ServerRow({ + config, + connection, + isConnecting, + expanded, + onToggle, + onConnect, + onDisconnect, + onEdit, + onDelete, + onTerminal, +}: { + config: SshServerConfig; + connection?: SshConnectionInfo; + isConnecting: boolean; + expanded: boolean; + onToggle: () => void; + onConnect: () => void; + onDisconnect: () => void; + onEdit: () => void; + onDelete: () => void; + onTerminal: () => void; +}) { + const isLive = !!connection; + const state: 'live' | 'connecting' | 'idle' = isConnecting ? 'connecting' : isLive ? 'live' : 'idle'; + + const [, force] = useState(0); + useEffect(() => { + if (!isLive) return; + const id = setInterval(() => force((n) => n + 1), 1000); + return () => clearInterval(id); + }, [isLive]); + + const auth = config.auth_method; + + return ( + +
+
+ {/* Per-server OS icon, falls back to Tux for unknown values */} + +
+ +
+
+ + {config.name} + + + {isLive && connection?.latency_ms !== undefined && ( + + {connection.latency_ms}ms + + )} +
+
+ + {config.username}@{config.host} + :{config.port} + +
+
+ +
+ {isLive && ( + + {uptimeStr(connection!.connected_at)} + + )} + {config.tags && config.tags.length > 0 && !isLive && ( +
+ {config.tags.slice(0, 2).map((t) => ( + {t} + ))} +
+ )} + + + +
+
+ + + {expanded && ( + +
+
+ + + {config.host}:{config.port} + + + + + {auth.type === 'key' ? ( + <> + + + {auth.path.split('/').pop()} + + + ) : ( + <> + + Password + + )} + + + {connection ? ( + <> + + + {connection.latency_ms ?? 'β€”'}ms + + + + + {uptimeStr(connection.connected_at)} + + + + ) : ( + <> + + + {config.default_directory || '~'} + + + + + Disconnected + + + + )} +
+ + {config.tags && config.tags.length > 0 && ( +
+ + {config.tags.map((t) => ( + {t} + ))} +
+ )} + +
+
+ {isLive ? ( + <> + + + Open terminal + + + + Disconnect + + + ) : ( + + {isConnecting ? ( + <> + + Connecting + + ) : ( + <>Connect + )} + + )} +
+ +
+ + + + + + + + + +
+
+
+
+ )} +
+
+ ); +} + +function MetaItem({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} + +function IconBtn({ + children, + onClick, + label, + danger, +}: { + children: React.ReactNode; + onClick?: (e: React.MouseEvent) => void; + label: string; + danger?: boolean; +}) { + return ( + + {children} + + ); +} + +function GroupHeader({ + title, + total, + live, + collapsed, + onToggle, +}: { + title: string; + total: number; + live: number; + collapsed: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +function ErrorBanner({ message, onDismiss }: { message: string; onDismiss: () => void }) { + return ( + +
+ +
+
+ Connection failed +
+
+ {message} +
+
+ +
+
+ ); +} + +type Filter = 'all' | 'live' | 'idle'; + +export function SshPanel({ + servers = [], + connections = [], + connecting, + error, + onSaveServer, + onDeleteServer, + onConnect, + onDisconnect, + onSpawnTerminal, +}: { + servers: SshServerConfig[]; + connections: SshConnectionInfo[]; + connecting?: string | null; + error?: string | null; + onSaveServer?: (config: SshServerConfig) => void; + onDeleteServer?: (id: string) => void; + onConnect?: (configId: string) => void; + onDisconnect?: (configId: string) => void; + onSpawnTerminal?: (configId: string, serverName: string) => void; +}) { + const [showForm, setShowForm] = useState(false); + const [editing, setEditing] = useState(); + const [filter, setFilter] = useState('all'); + const [query, setQuery] = useState(''); + const [expandedId, setExpandedId] = useState(null); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + const searchRef = useRef(null); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + searchRef.current?.focus(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + const connByConfig = useMemo(() => { + const m = new Map(); + connections.forEach((c) => m.set(c.config_id, c)); + return m; + }, [connections]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return servers.filter((s) => { + const live = connByConfig.has(s.id); + if (filter === 'live' && !live) return false; + if (filter === 'idle' && live) return false; + if (!q) return true; + return [s.name, s.host, s.username, s.group ?? '', ...(s.tags ?? [])] + .join(' ') + .toLowerCase() + .includes(q); + }); + }, [servers, filter, query, connByConfig]); + + const groups = useMemo(() => { + const map = new Map(); + filtered.forEach((s) => { + const g = s.group?.trim() || 'Ungrouped'; + if (!map.has(g)) map.set(g, []); + map.get(g)!.push(s); + }); + return Array.from(map.entries()).sort((a, b) => { + if (a[0] === 'Ungrouped') return 1; + if (b[0] === 'Ungrouped') return -1; + return a[0].localeCompare(b[0]); + }); + }, [filtered]); + + const toggleGroup = (name: string) => { + setCollapsedGroups((prev) => { + const n = new Set(prev); + if (n.has(name)) n.delete(name); + else n.add(name); + return n; + }); + }; + + const handleSave = (config: SshServerConfig) => { + onSaveServer?.(config); + setShowForm(false); + setEditing(undefined); + }; + + const handleEdit = (config: SshServerConfig) => { + setEditing(config); + setShowForm(true); + }; + + const liveCount = connections.length; + const totalCount = servers.length; + + return ( +
+
+
+ + + Connections + + + {totalCount} + + {liveCount > 0 && ( + + + + {liveCount} live + + + )} +
+
+ { + setEditing(undefined); + setShowForm((v) => !v); + }} + active={showForm} + > + + New + +
+
+ +
+
+ + setQuery(e.target.value)} + placeholder="Search..." + className="flex-1 min-w-0 bg-transparent text-[12px] text-white placeholder-[#525252] outline-none" + style={{ fontFamily: sans }} + /> + + ⌘K + +
+ + +
+ {(['all', 'live', 'idle'] as const).map((f) => ( + + ))} +
+
+
+ + {error && {}} />} + + + {showForm && ( + { + setShowForm(false); + setEditing(undefined); + }} + /> + )} + + +
+ {servers.length === 0 ? ( + setShowForm(true)} /> + ) : filtered.length === 0 ? ( + { + setQuery(''); + setFilter('all'); + }} + /> + ) : ( +
+ {groups.map(([groupName, items]) => { + const collapsed = collapsedGroups.has(groupName); + const liveInGroup = items.filter((s) => connByConfig.has(s.id)).length; + return ( +
+ toggleGroup(groupName)} + /> + + {!collapsed && ( + + {items.map((server) => ( + setExpandedId((id) => (id === server.id ? null : server.id))} + onConnect={() => onConnect?.(server.id)} + onDisconnect={() => onDisconnect?.(server.id)} + onEdit={() => handleEdit(server)} + onDelete={() => onDeleteServer?.(server.id)} + onTerminal={() => onSpawnTerminal?.(server.id, server.name)} + /> + ))} + + )} + +
+ ); + })} +
+ )} +
+ +
+
+ + + ssh-agent + + Β· + OpenSSH 9.6 +
+ + v0.4.2 + +
+
+ ); +} + +function EmptyState({ onAdd }: { onAdd: () => void }) { + return ( +
+
+ + +
+

+ No connections yet +

+

+ Add a remote machine to develop, deploy, and stream logs over SSH. +

+
+ + + New connection + + + Documentation + + +
+
+ ); +} + +function NoResults({ query, onClear }: { query: string; onClear: () => void }) { + return ( +
+
+ +
+

+ No matches +

+

+ {query ? ( + <> + Nothing matches{' '} + + "{query}" + + + ) : ( + 'No connections in this filter' + )} +

+ +
+ ); +} \ No newline at end of file diff --git a/src/hooks/useRemoteFileTree.ts b/src/hooks/useRemoteFileTree.ts new file mode 100644 index 0000000..b63d119 --- /dev/null +++ b/src/hooks/useRemoteFileTree.ts @@ -0,0 +1,31 @@ +import { useState, useCallback } from 'react'; +import { sshListDir, type SftpFileEntry } from '../lib/tauri'; + +export function useRemoteFileTree() { + const [files, setFiles] = useState([]); + const [currentPath, setCurrentPath] = useState('.'); + const [loading, setLoading] = useState(false); + + const loadDirectory = useCallback(async (configId: string, path: string) => { + setLoading(true); + try { + const result = await sshListDir(configId, path); + // Sort: dirs first, then alphabetical + const sorted = result.sort((a, b) => { + if (a.is_dir === b.is_dir) { + return a.name.localeCompare(b.name); + } + return a.is_dir ? -1 : 1; + }); + setFiles(sorted); + setCurrentPath(path); + } catch (err) { + console.error('[SSH] Failed to list remote directory:', err); + setFiles([]); + } finally { + setLoading(false); + } + }, []); + + return { files, currentPath, loading, loadDirectory }; +} diff --git a/src/hooks/useSessions.ts b/src/hooks/useSessions.ts index c1d36f4..b5e932e 100644 --- a/src/hooks/useSessions.ts +++ b/src/hooks/useSessions.ts @@ -26,9 +26,9 @@ export function useSessions() { loadSessions(); }, [loadSessions]); - const createSession = useCallback(async (title?: string) => { + const createSession = useCallback(async (title?: string, workdir?: string, sshConfigId?: string) => { try { - const session = await tauriCreateSession(title); + const session = await tauriCreateSession(title, workdir, sshConfigId); setSessions((prev) => [session, ...prev]); setActiveSessionId(session.id); return session; @@ -57,6 +57,12 @@ export function useSessions() { setActiveSessionId(sessionId); }, []); + const renameSessionLocal = useCallback((sessionId: string, newTitle: string) => { + setSessions((prev) => + prev.map((s) => (s.id === sessionId ? { ...s, title: newTitle } : s)) + ); + }, []); + return { sessions, activeSessionId, @@ -65,5 +71,6 @@ export function useSessions() { deleteSession, selectSession, loadSessions, + renameSessionLocal, }; } diff --git a/src/hooks/useSsh.ts b/src/hooks/useSsh.ts new file mode 100644 index 0000000..4939a9a --- /dev/null +++ b/src/hooks/useSsh.ts @@ -0,0 +1,190 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; +import { + sshListServers, + sshSaveServer, + sshUpdateServer, + sshDeleteServer, + sshConnect, + sshDisconnect, + sshGetConnections, + type SshServerConfig, + type SshConnectionInfo, +} from '../lib/tauri'; + +export interface SshState { + servers: SshServerConfig[]; + connections: SshConnectionInfo[]; + activeConnectionId: string | null; + loading: boolean; + connecting: string | null; // config_id currently connecting + error: string | null; +} + +export function useSsh() { + const [state, setState] = useState({ + servers: [], + connections: [], + activeConnectionId: null, + loading: false, + connecting: null, + error: null, + }); + + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { mountedRef.current = false; }; + }, []); + + // ── Load servers from disk ── + + const loadServers = useCallback(async () => { + try { + const servers = await sshListServers(); + if (mountedRef.current) { + setState(prev => ({ ...prev, servers })); + } + } catch (err) { + console.error('[SSH] Failed to load servers:', err); + } + }, []); + + // ── Load active connections ── + + const refreshConnections = useCallback(async () => { + try { + const connections = await sshGetConnections(); + if (mountedRef.current) { + setState(prev => ({ ...prev, connections })); + } + } catch (err) { + console.error('[SSH] Failed to refresh connections:', err); + } + }, []); + + // Auto-load on mount + useEffect(() => { + loadServers(); + refreshConnections(); + }, [loadServers, refreshConnections]); + + // ── Save a new or updated server ── + + const saveServer = useCallback(async (config: SshServerConfig) => { + setState(prev => ({ ...prev, error: null })); + try { + const existing = state.servers.find(s => s.id === config.id); + if (existing) { + await sshUpdateServer(config); + } else { + await sshSaveServer(config); + } + await loadServers(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setState(prev => ({ ...prev, error: msg })); + throw err; + } + }, [state.servers, loadServers]); + + // ── Delete a server ── + + const deleteServer = useCallback(async (id: string) => { + setState(prev => ({ ...prev, error: null })); + try { + await sshDeleteServer(id); + await loadServers(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setState(prev => ({ ...prev, error: msg })); + } + }, [loadServers]); + + // ── Connect to a server ── + + const connect = useCallback(async (configId: string) => { + setState(prev => ({ ...prev, connecting: configId, error: null })); + try { + const info = await sshConnect(configId); + if (mountedRef.current) { + setState(prev => ({ + ...prev, + connecting: null, + activeConnectionId: configId, + connections: [ + ...prev.connections.filter(c => c.config_id !== configId), + info, + ], + })); + } + return info; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (mountedRef.current) { + setState(prev => ({ ...prev, connecting: null, error: msg })); + } + throw err; + } + }, []); + + // ── Disconnect from a server ── + + const disconnect = useCallback(async (configId: string) => { + setState(prev => ({ ...prev, error: null })); + try { + await sshDisconnect(configId); + if (mountedRef.current) { + setState(prev => ({ + ...prev, + connections: prev.connections.filter(c => c.config_id !== configId), + activeConnectionId: + prev.activeConnectionId === configId ? null : prev.activeConnectionId, + })); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setState(prev => ({ ...prev, error: msg })); + } + }, []); + + // ── Set active connection ── + + const setActiveConnection = useCallback((configId: string | null) => { + setState(prev => ({ ...prev, activeConnectionId: configId })); + }, []); + + // ── Clear error ── + + const clearError = useCallback(() => { + setState(prev => ({ ...prev, error: null })); + }, []); + + // ── Derived state ── + + const activeConnection = state.connections.find( + c => c.config_id === state.activeConnectionId + ) ?? null; + + const isConnected = (configId: string) => + state.connections.some(c => c.config_id === configId); + + return { + servers: state.servers, + connections: state.connections, + activeConnection, + activeConnectionId: state.activeConnectionId, + connecting: state.connecting, + loading: state.loading, + error: state.error, + loadServers, + saveServer, + deleteServer, + connect, + disconnect, + setActiveConnection, + clearError, + isConnected, + refreshConnections, + }; +} diff --git a/src/hooks/useTerminal.ts b/src/hooks/useTerminal.ts index 914100f..a380292 100644 --- a/src/hooks/useTerminal.ts +++ b/src/hooks/useTerminal.ts @@ -5,6 +5,7 @@ import { init, Terminal, FitAddon } from 'ghostty-web'; export interface TerminalInstance { id: string; + label: string; terminal: Terminal; container: HTMLDivElement; fitAddon: FitAddon; @@ -24,7 +25,11 @@ export function useTerminal() { const closeTerminal = useCallback(async (id: string) => { try { - await invoke('close_terminal', { id }); + if (id.startsWith('ssh-shell-')) { + await invoke('ssh_close_shell', { shellId: id }); + } else { + await invoke('close_terminal', { id }); + } } catch {} const term = terminalsRef.current.find(t => t.id === id); @@ -49,25 +54,34 @@ export function useTerminal() { isInitializedRef.current = true; await init(); - const unData = listen<{id: string, data: number[]}>('pty:data', e => { + const handleData = (e: { payload: { id: string, data: number[] } }) => { const t = terminalsRef.current.find(term => term.id === e.payload.id); if (!t) return; const dec = new TextDecoder('utf-8', { fatal: false }); const text = dec.decode(new Uint8Array(e.payload.data), { stream: true }); if (text) t.terminal.write(text); - }); + }; - const unExit = listen<{id: string}>('pty:exit', e => { + const handleExit = (e: { payload: { id: string } }) => { closeTerminalRef.current(e.payload.id); - }); + }; + + const unData = listen<{id: string, data: number[]}>('pty:data', handleData); + const unExit = listen<{id: string}>('pty:exit', handleExit); + + // Listen for SSH terminal events + const unDataSSH = listen<{id: string, data: number[]}>('ssh:data', handleData); + const unExitSSH = listen<{id: string}>('ssh:exit', handleExit); return () => { unData.then(f => f()); unExit.then(f => f()); + unDataSSH.then(f => f()); + unExitSSH.then(f => f()); }; }, []); - const createTerminal = useCallback(async () => { + const createTerminal = useCallback(async (opts?: { type: 'local' } | { type: 'ssh', configId: string, serverName: string }) => { await initTerminal(); const container = document.createElement('div'); @@ -89,14 +103,29 @@ export function useTerminal() { term.open(container); fitAddon.fit(); - const id = await invoke('spawn_terminal'); + const type = opts?.type || 'local'; + let id: string; + let label: string; - term.onData((data: string) => { - invoke('write_terminal', { id, data }).catch(() => {}); - }); + if (type === 'local') { + label = 'Local'; + id = await invoke('spawn_terminal'); + term.onData((data: string) => { + invoke('write_terminal', { id, data }).catch(() => {}); + }); + } else if (opts?.type === 'ssh') { + label = `SSH: ${opts.serverName}`; + id = await invoke('ssh_spawn_shell', { configId: opts.configId }); + term.onData((data: string) => { + invoke('ssh_write_shell', { shellId: id, data }).catch(() => {}); + }); + } else { + throw new Error("Invalid terminal type"); + } const newInstance: TerminalInstance = { id, + label, terminal: term, container, fitAddon, diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index b2e1bbb..39da8dc 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -14,6 +14,8 @@ export interface ConfigInfo { export interface Session { id: string; title: string | null; + workdir: string | null; + ssh_config_id: string | null; created_at: string | null; updated_at: string | null; } @@ -76,8 +78,12 @@ export async function getSessions(): Promise { return invoke('get_sessions'); } -export async function createSession(title?: string): Promise { - return invoke('create_session', { title }); +export async function createSession(title?: string, workdir?: string, sshConfigId?: string): Promise { + return invoke('create_session', { title, workdir, sshConfigId }); +} + +export async function getHomeDir(): Promise { + return invoke('get_home_dir'); } export async function deleteSession(sessionId: string): Promise { @@ -114,6 +120,10 @@ export async function listDirectory(path: string): Promise> { + return invoke('list_local_directory', { path }); +} + export async function findFiles(query: string): Promise { return invoke('find_files', { query }); } @@ -250,3 +260,95 @@ export async function resizeTerminal(id: string, cols: number, rows: number): Pr export async function closeTerminal(id: string): Promise { return invoke('close_terminal', { id }); } + +// ── SSH Types ── + +export interface SshServerConfig { + id: string; + name: string; + host: string; + port: number; + username: string; + auth_method: SshAuthMethod; + default_directory?: string; + group?: string; + tags?: string[]; + os?: string; +} + +export type SshAuthMethod = + | { type: 'password'; password: string } + | { type: 'key'; path: string; passphrase?: string }; + +export interface SshConnectionInfo { + config_id: string; + server_name: string; + connected: boolean; + os?: string; +} + +export interface SftpFileEntry { + name: string; + path: string; + is_dir: boolean; + size: number; +} + +// ── SSH Commands ── + +export async function sshListServers(): Promise { + return invoke('ssh_list_servers'); +} + +export async function sshSaveServer(config: SshServerConfig): Promise { + return invoke('ssh_save_server', { config }); +} + +export async function sshUpdateServer(config: SshServerConfig): Promise { + return invoke('ssh_update_server', { config }); +} + +export async function sshDeleteServer(id: string): Promise { + return invoke('ssh_delete_server', { id }); +} + +export async function sshConnect(configId: string): Promise { + return invoke('ssh_connect', { configId }); +} + +export async function sshDisconnect(configId: string): Promise { + return invoke('ssh_disconnect', { configId }); +} + +export async function sshGetConnections(): Promise { + return invoke('ssh_get_connections'); +} + +export async function sshListDir(configId: string, path: string): Promise { + return invoke('ssh_list_dir', { configId, path }); +} + +export async function sshReadFile(configId: string, path: string): Promise { + return invoke('ssh_read_file', { configId, path }); +} + +export async function sshWriteFile(configId: string, path: string, content: string): Promise { + return invoke('ssh_write_file', { configId, path, content }); +} + +export async function sshSpawnShell(configId: string): Promise { + return invoke('ssh_spawn_shell', { configId }); +} + +export async function sshWriteShell(shellId: string, data: string): Promise { + return invoke('ssh_write_shell', { shellId, data }); +} + +export async function sshCloseShell(shellId: string): Promise { + return invoke('ssh_close_shell', { shellId }); +} + +export async function sshExecCommand(configId: string, command: string): Promise { + return invoke('ssh_exec_command', { configId, command }); +} + diff --git a/src/main.tsx b/src/main.tsx index cbaf2c4..012ebe9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,13 @@ import { createRoot } from 'react-dom/client'; import App from './components/App'; import './index.css'; +window.addEventListener('error', (e) => { + const err = document.createElement('div'); + err.style.cssText = 'position:fixed;top:0;left:0;z-index:9999;background:red;color:white;padding:20px;font-size:16px;white-space:pre-wrap;'; + err.textContent = e.error?.stack || e.message; + document.body.appendChild(err); +}); + createRoot(document.getElementById('root')!).render(