GitHub improvements
This commit is contained in:
@@ -52,7 +52,7 @@ Built with [Wails v2](https://wails.io/) (Go backend, HTML/CSS/JS frontend, [xte
|
|||||||
### What You'll Need
|
### What You'll Need
|
||||||
|
|
||||||
| Tool | Version | Where to get it |
|
| Tool | Version | Where to get it |
|
||||||
|------|---------|-----------------|
|
| --------- | ------- | ---------------------------------------------------------- |
|
||||||
| Go | 1.23+ | [golang.org/dl](https://golang.org/dl/) |
|
| Go | 1.23+ | [golang.org/dl](https://golang.org/dl/) |
|
||||||
| Node.js | 18+ | [nodejs.org](https://nodejs.org/) |
|
| Node.js | 18+ | [nodejs.org](https://nodejs.org/) |
|
||||||
| Wails CLI | v2 | `go install github.com/wailsapp/wails/v2/cmd/wails@latest` |
|
| Wails CLI | v2 | `go install github.com/wailsapp/wails/v2/cmd/wails@latest` |
|
||||||
@@ -81,7 +81,7 @@ If you just want to connect to a server without setting up a full profile, press
|
|||||||
All shortcuts use **Cmd** on macOS and **Ctrl** on Windows/Linux.
|
All shortcuts use **Cmd** on macOS and **Ctrl** on Windows/Linux.
|
||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
|----------|--------|
|
| ----------------- | --------------------------------- |
|
||||||
| Cmd + K | Open command palette |
|
| Cmd + K | Open command palette |
|
||||||
| Cmd + N | New connection |
|
| Cmd + N | New connection |
|
||||||
| Cmd + W | Close current tab |
|
| Cmd + W | Close current tab |
|
||||||
@@ -156,7 +156,7 @@ Press **Cmd+Shift+S** to open the snippet library. Save frequently-used commands
|
|||||||
Press **Cmd+F** to open the search overlay on the active terminal. Features include:
|
Press **Cmd+F** to open the search overlay on the active terminal. Features include:
|
||||||
|
|
||||||
- Case-sensitive toggle (**Aa** button)
|
- Case-sensitive toggle (**Aa** button)
|
||||||
- Regular expression toggle (**.*** button)
|
- Regular expression toggle (**.\*** button)
|
||||||
- Match count indicator
|
- Match count indicator
|
||||||
- Navigate through matches with up/down arrows or Enter/Shift+Enter
|
- Navigate through matches with up/down arrows or Enter/Shift+Enter
|
||||||
|
|
||||||
@@ -204,7 +204,7 @@ Nexus Terminal takes credential storage seriously. Your passwords and private ke
|
|||||||
### How the Encryption Works
|
### How the Encryption Works
|
||||||
|
|
||||||
| Detail | Value |
|
| Detail | Value |
|
||||||
|--------|-------|
|
| -------------- | -------------------------------------------------- |
|
||||||
| Cipher | AES-256-GCM |
|
| Cipher | AES-256-GCM |
|
||||||
| Key derivation | Argon2id — 128 MiB memory, 3 iterations, 4 threads |
|
| Key derivation | Argon2id — 128 MiB memory, 3 iterations, 4 threads |
|
||||||
| Key length | 256 bits (32 bytes) |
|
| Key length | 256 bits (32 bytes) |
|
||||||
@@ -273,7 +273,7 @@ frontend/
|
|||||||
## Building & Testing
|
## Building & Testing
|
||||||
|
|
||||||
| Command | What it does |
|
| Command | What it does |
|
||||||
|---------|-------------|
|
| ------------ | --------------------------------------------------------- |
|
||||||
| `make deps` | Install Go modules and npm packages |
|
| `make deps` | Install Go modules and npm packages |
|
||||||
| `make build` | Production build → `build/bin/nexus-terminal.app` (macOS) |
|
| `make build` | Production build → `build/bin/nexus-terminal.app` (macOS) |
|
||||||
| `make dev` | Development mode with hot reload |
|
| `make dev` | Development mode with hot reload |
|
||||||
@@ -323,7 +323,7 @@ Then reopen your terminal so `gcc` is on `PATH` and retry `build.ps1`.
|
|||||||
## Data Locations
|
## Data Locations
|
||||||
|
|
||||||
| Platform | Database | Vault | Recordings |
|
| Platform | Database | Vault | Recordings |
|
||||||
|----------|----------|-------|------------|
|
| -------- | ------------------------------------------------------ | ----------- | ----------------- |
|
||||||
| macOS | `~/Library/Application Support/NexusTerminal/nexus.db` | `.../vault` | `.../recordings/` |
|
| macOS | `~/Library/Application Support/NexusTerminal/nexus.db` | `.../vault` | `.../recordings/` |
|
||||||
| Linux | `~/.config/NexusTerminal/nexus.db` | `.../vault` | `.../recordings/` |
|
| Linux | `~/.config/NexusTerminal/nexus.db` | `.../vault` | `.../recordings/` |
|
||||||
| Windows | `%APPDATA%\NexusTerminal\nexus.db` | `...\vault` | `...\recordings\` |
|
| Windows | `%APPDATA%\NexusTerminal\nexus.db` | `...\vault` | `...\recordings\` |
|
||||||
@@ -360,9 +360,11 @@ Check the character encoding setting on the connection profile. Most modern serv
|
|||||||
|
|
||||||
**Build fails with "wails: command not found"**
|
**Build fails with "wails: command not found"**
|
||||||
Install the Wails CLI:
|
Install the Wails CLI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Make sure `$GOPATH/bin` (or `$HOME/go/bin`) is in your `PATH`.
|
Make sure `$GOPATH/bin` (or `$HOME/go/bin`) is in your `PATH`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"fixed": {
|
||||||
|
"file_version": "{{.Info.ProductVersion}}"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"0000": {
|
||||||
|
"ProductVersion": "{{.Info.ProductVersion}}",
|
||||||
|
"CompanyName": "{{.Info.CompanyName}}",
|
||||||
|
"FileDescription": "{{.Info.ProductName}}",
|
||||||
|
"LegalCopyright": "{{.Info.Copyright}}",
|
||||||
|
"ProductName": "{{.Info.ProductName}}",
|
||||||
|
"Comments": "{{.Info.Comments}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
|
||||||
|
<dependency>
|
||||||
|
<dependentAssembly>
|
||||||
|
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||||
|
</dependentAssembly>
|
||||||
|
</dependency>
|
||||||
|
<asmv3:application>
|
||||||
|
<asmv3:windowsSettings>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||||
|
</asmv3:windowsSettings>
|
||||||
|
</asmv3:application>
|
||||||
|
</assembly>
|
||||||
+142
-52
@@ -1,31 +1,30 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Nexus Terminal</title>
|
<title>Nexus Terminal</title>
|
||||||
|
|
||||||
<!-- Vendor styles -->
|
<!-- Vendor styles -->
|
||||||
<link rel="stylesheet" href="/src/css/lib/xterm.css">
|
<link rel="stylesheet" href="/src/css/lib/xterm.css" />
|
||||||
|
|
||||||
<!-- Design system -->
|
<!-- Design system -->
|
||||||
<link rel="stylesheet" href="/src/css/tokens.css">
|
<link rel="stylesheet" href="/src/css/tokens.css" />
|
||||||
<link rel="stylesheet" href="/src/css/base.css">
|
<link rel="stylesheet" href="/src/css/base.css" />
|
||||||
<link rel="stylesheet" href="/src/css/components.css">
|
<link rel="stylesheet" href="/src/css/components.css" />
|
||||||
<link rel="stylesheet" href="/src/css/layout.css">
|
<link rel="stylesheet" href="/src/css/layout.css" />
|
||||||
<link rel="stylesheet" href="/src/css/sidebar.css">
|
<link rel="stylesheet" href="/src/css/sidebar.css" />
|
||||||
<link rel="stylesheet" href="/src/css/tabs.css">
|
<link rel="stylesheet" href="/src/css/tabs.css" />
|
||||||
<link rel="stylesheet" href="/src/css/terminal.css">
|
<link rel="stylesheet" href="/src/css/terminal.css" />
|
||||||
<link rel="stylesheet" href="/src/css/splitpane.css">
|
<link rel="stylesheet" href="/src/css/splitpane.css" />
|
||||||
<link rel="stylesheet" href="/src/css/recordings.css">
|
<link rel="stylesheet" href="/src/css/recordings.css" />
|
||||||
<link rel="stylesheet" href="/src/css/sftp.css">
|
<link rel="stylesheet" href="/src/css/sftp.css" />
|
||||||
<link rel="stylesheet" href="/src/css/forms.css">
|
<link rel="stylesheet" href="/src/css/forms.css" />
|
||||||
<link rel="stylesheet" href="/src/css/modals.css">
|
<link rel="stylesheet" href="/src/css/modals.css" />
|
||||||
<link rel="stylesheet" href="/src/css/github.css">
|
<link rel="stylesheet" href="/src/css/github.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app" class="app">
|
<div id="app" class="app">
|
||||||
|
|
||||||
<!-- ============================================================
|
<!-- ============================================================
|
||||||
Sidebar — connection manager
|
Sidebar — connection manager
|
||||||
============================================================ -->
|
============================================================ -->
|
||||||
@@ -33,9 +32,17 @@
|
|||||||
<div class="sidebar-header wails-drag">
|
<div class="sidebar-header wails-drag">
|
||||||
<div class="sidebar-logo">
|
<div class="sidebar-logo">
|
||||||
<!-- Terminal prompt icon -->
|
<!-- Terminal prompt icon -->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"
|
<svg
|
||||||
fill="none" stroke="currentColor" stroke-width="2"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
<polyline points="4 17 10 11 4 5"></polyline>
|
<polyline points="4 17 10 11 4 5"></polyline>
|
||||||
<line x1="12" y1="19" x2="20" y2="19"></line>
|
<line x1="12" y1="19" x2="20" y2="19"></line>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -45,12 +52,14 @@
|
|||||||
|
|
||||||
<div class="sidebar-search">
|
<div class="sidebar-search">
|
||||||
<div class="search-wrapper">
|
<div class="search-wrapper">
|
||||||
<input type="text"
|
<input
|
||||||
|
type="text"
|
||||||
class="search-input"
|
class="search-input"
|
||||||
id="profile-search"
|
id="profile-search"
|
||||||
placeholder="Search connections…"
|
placeholder="Search connections…"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false">
|
spellcheck="false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -60,45 +69,98 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<button class="btn btn-ghost btn-sm" id="btn-new-connection" title="New Connection">
|
<button
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
id="btn-new-connection"
|
||||||
|
title="New Connection"
|
||||||
|
>
|
||||||
<span>+ New</span>
|
<span>+ New</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost btn-icon btn-sm" id="btn-import" title="Import Profiles">
|
<button
|
||||||
|
class="btn btn-ghost btn-icon btn-sm"
|
||||||
|
id="btn-import"
|
||||||
|
title="Import Profiles"
|
||||||
|
>
|
||||||
<!-- Upload icon -->
|
<!-- Upload icon -->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
<svg
|
||||||
fill="none" stroke="currentColor" stroke-width="2"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
<polyline points="17 8 12 3 7 8"></polyline>
|
<polyline points="17 8 12 3 7 8"></polyline>
|
||||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost btn-icon btn-sm" id="btn-export" title="Export Profiles">
|
<button
|
||||||
|
class="btn btn-ghost btn-icon btn-sm"
|
||||||
|
id="btn-export"
|
||||||
|
title="Export Profiles"
|
||||||
|
>
|
||||||
<!-- Download icon -->
|
<!-- Download icon -->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
<svg
|
||||||
fill="none" stroke="currentColor" stroke-width="2"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
<polyline points="7 10 12 15 17 10"></polyline>
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost btn-icon btn-sm" id="btn-recordings" title="Session Recordings">
|
<button
|
||||||
|
class="btn btn-ghost btn-icon btn-sm"
|
||||||
|
id="btn-recordings"
|
||||||
|
title="Session Recordings"
|
||||||
|
>
|
||||||
<!-- Film icon -->
|
<!-- Film icon -->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
<svg
|
||||||
fill="none" stroke="currentColor" stroke-width="2"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
<circle cx="12" cy="12" r="10"></circle>
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
<circle cx="12" cy="12" r="3"></circle>
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost btn-icon btn-sm" id="btn-settings" title="Settings">
|
<button
|
||||||
|
class="btn btn-ghost btn-icon btn-sm"
|
||||||
|
id="btn-settings"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
<!-- Cog icon -->
|
<!-- Cog icon -->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
|
<svg
|
||||||
fill="none" stroke="currentColor" stroke-width="2"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
<circle cx="12" cy="12" r="3"></circle>
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0
|
<path
|
||||||
|
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0
|
||||||
0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65
|
0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65
|
||||||
0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0
|
0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0
|
||||||
0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9
|
0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9
|
||||||
@@ -114,7 +176,8 @@
|
|||||||
2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65
|
2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65
|
||||||
1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51
|
1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51
|
||||||
1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65
|
1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65
|
||||||
1.65 0 0 0-1.51 1z"></path>
|
1.65 0 0 0-1.51 1z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,11 +191,23 @@
|
|||||||
<!-- Tabs are injected dynamically by JS -->
|
<!-- Tabs are injected dynamically by JS -->
|
||||||
</div>
|
</div>
|
||||||
<button class="tab-new" id="btn-new-tab" title="New Tab">+</button>
|
<button class="tab-new" id="btn-new-tab" title="New Tab">+</button>
|
||||||
<button class="tab-broadcast" id="btn-broadcast" title="Toggle Broadcast Mode (Ctrl+Shift+B)">
|
<button
|
||||||
|
class="tab-broadcast"
|
||||||
|
id="btn-broadcast"
|
||||||
|
title="Toggle Broadcast Mode (Ctrl+Shift+B)"
|
||||||
|
>
|
||||||
<!-- Broadcast icon (loudspeaker) -->
|
<!-- Broadcast icon (loudspeaker) -->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
|
<svg
|
||||||
fill="none" stroke="currentColor" stroke-width="2"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
<path d="M19.69 14a6.9 6.9 0 0 0 .31-2 6.9 6.9 0 0 0-.31-2"></path>
|
<path d="M19.69 14a6.9 6.9 0 0 0 .31-2 6.9 6.9 0 0 0-.31-2"></path>
|
||||||
<path d="M22.82 16.18a10 10 0 0 0 0-8.36"></path>
|
<path d="M22.82 16.18a10 10 0 0 0 0-8.36"></path>
|
||||||
<path d="M1 12s3-7 9-7 9 7 9 7-3 7-9 7-9-7-9-7z"></path>
|
<path d="M1 12s3-7 9-7 9 7 9 7-3 7-9 7-9-7-9-7z"></path>
|
||||||
@@ -149,18 +224,33 @@
|
|||||||
<div class="empty-state" id="empty-state">
|
<div class="empty-state" id="empty-state">
|
||||||
<div class="empty-state-icon">
|
<div class="empty-state-icon">
|
||||||
<!-- Terminal icon -->
|
<!-- Terminal icon -->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"
|
<svg
|
||||||
fill="none" stroke="currentColor" stroke-width="1.5"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
<polyline points="4 17 10 11 4 5"></polyline>
|
<polyline points="4 17 10 11 4 5"></polyline>
|
||||||
<line x1="12" y1="19" x2="20" y2="19"></line>
|
<line x1="12" y1="19" x2="20" y2="19"></line>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2>No active sessions</h2>
|
<h2>No active sessions</h2>
|
||||||
<p>Select a connection from the sidebar or create a new one to get started.</p>
|
<p>
|
||||||
|
Select a connection from the sidebar or create a new one to get
|
||||||
|
started.
|
||||||
|
</p>
|
||||||
<div class="empty-state-actions">
|
<div class="empty-state-actions">
|
||||||
<button class="btn btn-primary" id="btn-empty-connect">New Connection</button>
|
<button class="btn btn-primary" id="btn-empty-connect">
|
||||||
<button class="btn btn-secondary" id="btn-empty-quick">Quick Connect</button>
|
New Connection
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" id="btn-empty-quick">
|
||||||
|
Quick Connect
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,38 +4,38 @@
|
|||||||
*
|
*
|
||||||
* Run automatically via the postinstall hook in package.json.
|
* Run automatically via the postinstall hook in package.json.
|
||||||
*/
|
*/
|
||||||
'use strict';
|
"use strict";
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require("fs");
|
||||||
const path = require('path');
|
const path = require("path");
|
||||||
|
|
||||||
const frontendDir = path.resolve(__dirname, '..');
|
const frontendDir = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
// Mapping: source (relative to node_modules) → destination (relative to frontend/)
|
// Mapping: source (relative to node_modules) → destination (relative to frontend/)
|
||||||
const assets = [
|
const assets = [
|
||||||
{
|
{
|
||||||
src: 'xterm/css/xterm.css',
|
src: "xterm/css/xterm.css",
|
||||||
dest: 'src/css/lib/xterm.css',
|
dest: "src/css/lib/xterm.css",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'xterm/lib/xterm.js',
|
src: "xterm/lib/xterm.js",
|
||||||
dest: 'src/js/lib/xterm.min.js',
|
dest: "src/js/lib/xterm.min.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'xterm-addon-fit/lib/xterm-addon-fit.js',
|
src: "xterm-addon-fit/lib/xterm-addon-fit.js",
|
||||||
dest: 'src/js/lib/xterm-addon-fit.min.js',
|
dest: "src/js/lib/xterm-addon-fit.min.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'xterm-addon-search/lib/xterm-addon-search.js',
|
src: "xterm-addon-search/lib/xterm-addon-search.js",
|
||||||
dest: 'src/js/lib/xterm-addon-search.min.js',
|
dest: "src/js/lib/xterm-addon-search.min.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'xterm-addon-unicode11/lib/xterm-addon-unicode11.js',
|
src: "xterm-addon-unicode11/lib/xterm-addon-unicode11.js",
|
||||||
dest: 'src/js/lib/xterm-addon-unicode11.min.js',
|
dest: "src/js/lib/xterm-addon-unicode11.min.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'xterm-addon-web-links/lib/xterm-addon-web-links.js',
|
src: "xterm-addon-web-links/lib/xterm-addon-web-links.js",
|
||||||
dest: 'src/js/lib/xterm-addon-web-links.min.js',
|
dest: "src/js/lib/xterm-addon-web-links.min.js",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ function ensureDir(filePath) {
|
|||||||
* Copies a single asset, logging the result.
|
* Copies a single asset, logging the result.
|
||||||
*/
|
*/
|
||||||
function copyAsset(entry) {
|
function copyAsset(entry) {
|
||||||
const srcPath = path.join(frontendDir, 'node_modules', entry.src);
|
const srcPath = path.join(frontendDir, "node_modules", entry.src);
|
||||||
const destPath = path.join(frontendDir, entry.dest);
|
const destPath = path.join(frontendDir, entry.dest);
|
||||||
|
|
||||||
if (!fs.existsSync(srcPath)) {
|
if (!fs.existsSync(srcPath)) {
|
||||||
@@ -69,7 +69,7 @@ function copyAsset(entry) {
|
|||||||
|
|
||||||
// --- Main ---
|
// --- Main ---
|
||||||
|
|
||||||
console.log('[copy-assets] Copying xterm library files…');
|
console.log("[copy-assets] Copying xterm library files…");
|
||||||
|
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
for (const entry of assets) {
|
for (const entry of assets) {
|
||||||
@@ -84,4 +84,4 @@ if (failures > 0) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[copy-assets] All assets copied successfully.');
|
console.log("[copy-assets] All assets copied successfully.");
|
||||||
|
|||||||
@@ -174,6 +174,12 @@
|
|||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.github-repo-commit {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.github-repo-name {
|
.github-repo-name {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -190,6 +196,35 @@
|
|||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.github-repo-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-repo-download,
|
||||||
|
.github-repo-visit {
|
||||||
|
padding: 0 6px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-repo-download:hover,
|
||||||
|
.github-repo-visit:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--protocol-github);
|
||||||
|
border-color: var(--protocol-github);
|
||||||
|
}
|
||||||
|
|
||||||
.github-repo-private {
|
.github-repo-private {
|
||||||
background: rgba(255, 167, 38, 0.12);
|
background: rgba(255, 167, 38, 0.12);
|
||||||
color: #ffa726;
|
color: #ffa726;
|
||||||
|
|||||||
@@ -53,6 +53,34 @@
|
|||||||
padding-bottom: var(--space-2);
|
padding-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------
|
||||||
|
Bulk actions bar (multi-select)
|
||||||
|
----------------------------------------------------------------------- */
|
||||||
|
.sidebar-bulk-actions {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-bulk-count {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-bulk-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------
|
/* -----------------------------------------------------------------------
|
||||||
Sections (Favourites, Recent, All Connections)
|
Sections (Favourites, Recent, All Connections)
|
||||||
----------------------------------------------------------------------- */
|
----------------------------------------------------------------------- */
|
||||||
@@ -167,6 +195,15 @@
|
|||||||
background: var(--bg-active);
|
background: var(--bg-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-item-selected {
|
||||||
|
background: var(--accent-muted);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item-selected .sidebar-item-name {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Active / selected state */
|
/* Active / selected state */
|
||||||
.sidebar-item.active {
|
.sidebar-item.active {
|
||||||
background: var(--accent-muted);
|
background: var(--accent-muted);
|
||||||
|
|||||||
+489
-213
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,18 @@ class GitHubViewer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the status line for a session.
|
||||||
|
* @param {string} sessionID
|
||||||
|
* @param {string} message
|
||||||
|
*/
|
||||||
|
setStatus(sessionID, message) {
|
||||||
|
const viewer = this._viewers.get(sessionID);
|
||||||
|
if (viewer) {
|
||||||
|
viewer.setStatus(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles an error event for a session.
|
* Handles an error event for a session.
|
||||||
* @param {string} sessionID
|
* @param {string} sessionID
|
||||||
@@ -165,6 +177,16 @@ class GitHubViewerInstance {
|
|||||||
this._renderList();
|
this._renderList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the status line with a custom message.
|
||||||
|
* @param {string} message
|
||||||
|
*/
|
||||||
|
setStatus(message) {
|
||||||
|
if (!this._statusEl) return;
|
||||||
|
this._statusEl.classList.remove('github-viewer-status-error');
|
||||||
|
this._statusEl.textContent = message || '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays an error message in the status bar.
|
* Displays an error message in the status bar.
|
||||||
* @param {string} message
|
* @param {string} message
|
||||||
@@ -272,6 +294,7 @@ class GitHubViewerInstance {
|
|||||||
nameEl.textContent = repo.name;
|
nameEl.textContent = repo.name;
|
||||||
topRow.appendChild(nameEl);
|
topRow.appendChild(nameEl);
|
||||||
|
|
||||||
|
|
||||||
if (repo.private) {
|
if (repo.private) {
|
||||||
const badge = createElement('span', 'github-repo-badge github-repo-private');
|
const badge = createElement('span', 'github-repo-badge github-repo-private');
|
||||||
badge.textContent = 'Private';
|
badge.textContent = 'Private';
|
||||||
@@ -288,8 +311,46 @@ class GitHubViewerInstance {
|
|||||||
topRow.appendChild(badge);
|
topRow.appendChild(badge);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const actions = createElement('div', 'github-repo-actions');
|
||||||
|
|
||||||
|
const downloadBtn = createElement('button', 'github-repo-download', {
|
||||||
|
textContent: 'ZIP',
|
||||||
|
title: 'Download repository as zip',
|
||||||
|
});
|
||||||
|
downloadBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this._downloadRepo(repo);
|
||||||
|
});
|
||||||
|
actions.appendChild(downloadBtn);
|
||||||
|
|
||||||
|
const visitLink = createElement('button', 'github-repo-visit', {
|
||||||
|
textContent: 'Visit',
|
||||||
|
title: 'Open repository in browser',
|
||||||
|
});
|
||||||
|
visitLink.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (repo.htmlURL) {
|
||||||
|
if (window.runtime && window.runtime.BrowserOpenURL) {
|
||||||
|
window.runtime.BrowserOpenURL(repo.htmlURL);
|
||||||
|
} else {
|
||||||
|
window.open(repo.htmlURL, '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
actions.appendChild(visitLink);
|
||||||
|
|
||||||
|
topRow.appendChild(actions);
|
||||||
|
|
||||||
card.appendChild(topRow);
|
card.appendChild(topRow);
|
||||||
|
|
||||||
|
if (repo.latestCommitter && repo.latestCommitAt) {
|
||||||
|
const commitLine = createElement('div', 'github-repo-commit');
|
||||||
|
const when = new Date(repo.latestCommitAt);
|
||||||
|
const whenText = isNaN(when.getTime()) ? repo.latestCommitAt : when.toLocaleString();
|
||||||
|
commitLine.textContent = `Last commit: ${whenText} by ${repo.latestCommitter}`;
|
||||||
|
card.appendChild(commitLine);
|
||||||
|
}
|
||||||
|
|
||||||
// Description
|
// Description
|
||||||
if (repo.description) {
|
if (repo.description) {
|
||||||
const desc = createElement('div', 'github-repo-desc');
|
const desc = createElement('div', 'github-repo-desc');
|
||||||
@@ -324,6 +385,25 @@ class GitHubViewerInstance {
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _downloadRepo(repo) {
|
||||||
|
if (!this._statusEl) return;
|
||||||
|
const ref = repo.defaultBranch || '';
|
||||||
|
this._statusEl.classList.remove('github-viewer-status-error');
|
||||||
|
this._statusEl.textContent = `Downloading ${repo.fullName}...`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.go.api.Service.DownloadGitHubRepoArchive(
|
||||||
|
this.sessionID,
|
||||||
|
repo.fullName,
|
||||||
|
ref,
|
||||||
|
);
|
||||||
|
this._updateStatus();
|
||||||
|
} catch (err) {
|
||||||
|
this._statusEl.textContent = `⚠ Download failed: ${err}`;
|
||||||
|
this._statusEl.classList.add('github-viewer-status-error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async _manualRefresh() {
|
async _manualRefresh() {
|
||||||
if (!this._statusEl) return;
|
if (!this._statusEl) return;
|
||||||
this._statusEl.textContent = 'Refreshing…';
|
this._statusEl.textContent = 'Refreshing…';
|
||||||
|
|||||||
@@ -183,12 +183,19 @@ class ProfileEditor {
|
|||||||
const testBtn = createElement('button', 'btn btn-outline', { textContent: 'Test' });
|
const testBtn = createElement('button', 'btn btn-outline', { textContent: 'Test' });
|
||||||
const saveBtn = createElement('button', 'btn btn-primary', { textContent: isNew ? 'Create' : 'Save' });
|
const saveBtn = createElement('button', 'btn btn-primary', { textContent: isNew ? 'Create' : 'Save' });
|
||||||
const connectBtn = createElement('button', 'btn btn-accent', { textContent: isNew ? 'Create & Connect' : 'Save & Connect' });
|
const connectBtn = createElement('button', 'btn btn-accent', { textContent: isNew ? 'Create & Connect' : 'Save & Connect' });
|
||||||
|
const deleteBtn = !isNew
|
||||||
|
? createElement('button', 'btn btn-danger', { textContent: 'Delete' })
|
||||||
|
: null;
|
||||||
|
|
||||||
cancelBtn.addEventListener('click', () => this.close());
|
cancelBtn.addEventListener('click', () => this.close());
|
||||||
testBtn.addEventListener('click', () => this._testConnection());
|
testBtn.addEventListener('click', () => this._testConnection());
|
||||||
saveBtn.addEventListener('click', () => this._handleSave(false));
|
saveBtn.addEventListener('click', () => this._handleSave(false));
|
||||||
connectBtn.addEventListener('click', () => this._handleSave(true));
|
connectBtn.addEventListener('click', () => this._handleSave(true));
|
||||||
|
if (deleteBtn) {
|
||||||
|
deleteBtn.addEventListener('click', () => this._handleDelete());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteBtn) footer.appendChild(deleteBtn);
|
||||||
footer.appendChild(cancelBtn);
|
footer.appendChild(cancelBtn);
|
||||||
footer.appendChild(testBtn);
|
footer.appendChild(testBtn);
|
||||||
footer.appendChild(saveBtn);
|
footer.appendChild(saveBtn);
|
||||||
@@ -1464,6 +1471,22 @@ class ProfileEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
async _handleDelete() {
|
||||||
|
const ok = await this._confirmDelete();
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.go.api.Service.DeleteProfile(this._profile.id);
|
||||||
|
this.app.state.removeProfile(this._profile.id);
|
||||||
|
this.app.sidebar.render();
|
||||||
|
this.app.notifications.success('Profile deleted');
|
||||||
|
this.close();
|
||||||
|
} catch (err) {
|
||||||
|
this.app.notifications.error('Failed to delete profile: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @private */
|
/** @private */
|
||||||
async _testConnection() {
|
async _testConnection() {
|
||||||
this._collectFormValues();
|
this._collectFormValues();
|
||||||
@@ -1485,6 +1508,51 @@ class ProfileEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a confirmation modal for profile deletion.
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_confirmDelete() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const backdrop = createElement('div', 'modal-backdrop');
|
||||||
|
const modal = createElement('div', 'modal modal-sm');
|
||||||
|
const label = escapeHtml(this._profile.displayName || this._profile.host || 'Untitled');
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Delete Profile</h3>
|
||||||
|
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p style="font-size:var(--text-sm);color:var(--text-secondary)">
|
||||||
|
Delete <strong>${label}</strong>? This cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-sm confirm-cancel">Cancel</button>
|
||||||
|
<button class="btn btn-sm btn-danger confirm-ok">Delete</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
backdrop.appendChild(modal);
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
backdrop.classList.add('is-visible');
|
||||||
|
modal.classList.add('is-visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = (val) => {
|
||||||
|
backdrop.classList.remove('is-visible');
|
||||||
|
modal.classList.remove('is-visible');
|
||||||
|
setTimeout(() => backdrop.remove(), 200);
|
||||||
|
resolve(val);
|
||||||
|
};
|
||||||
|
modal.querySelector('.modal-close').addEventListener('click', () => close(false));
|
||||||
|
modal.querySelector('.confirm-cancel').addEventListener('click', () => close(false));
|
||||||
|
modal.querySelector('.confirm-ok').addEventListener('click', () => close(true));
|
||||||
|
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(false); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Utilities ────────────────────────────────────────────────────
|
// ── Utilities ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ class Settings {
|
|||||||
/**
|
/**
|
||||||
* Opens the settings modal.
|
* Opens the settings modal.
|
||||||
*/
|
*/
|
||||||
async open() {
|
async open(tabKey) {
|
||||||
this._prefs = JSON.parse(JSON.stringify(this.app.state.preferences));
|
this._prefs = JSON.parse(JSON.stringify(this.app.state.preferences));
|
||||||
this._activeTab = 'general';
|
this._activeTab = tabKey || this._activeTab || 'general';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this._knownHosts = await window.go.api.Service.ListKnownHosts() || [];
|
this._knownHosts = await window.go.api.Service.ListKnownHosts() || [];
|
||||||
@@ -55,6 +55,15 @@ class Settings {
|
|||||||
this._render();
|
this._render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the settings modal on a specific tab.
|
||||||
|
* @param {string} tabKey
|
||||||
|
*/
|
||||||
|
async openTab(tabKey) {
|
||||||
|
this._activeTab = tabKey || 'general';
|
||||||
|
return this.open(this._activeTab);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Closes the settings modal.
|
* Closes the settings modal.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+314
-2
@@ -19,11 +19,22 @@ class Sidebar {
|
|||||||
this.el = document.getElementById('sidebar-profiles');
|
this.el = document.getElementById('sidebar-profiles');
|
||||||
this.searchQuery = '';
|
this.searchQuery = '';
|
||||||
|
|
||||||
|
/** @type {Set<string>} */
|
||||||
|
this.selectedProfiles = new Set();
|
||||||
|
|
||||||
// Section collapse state
|
// Section collapse state
|
||||||
this.collapsedSections = new Set();
|
this.collapsedSections = new Set();
|
||||||
|
|
||||||
// Dismiss context menus on any outside click
|
// Dismiss context menus on any outside click
|
||||||
document.addEventListener('click', () => dismissContextMenus());
|
document.addEventListener('click', () => dismissContextMenus());
|
||||||
|
|
||||||
|
if (this.el) {
|
||||||
|
this.el.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.closest('.sidebar-item') && this.selectedProfiles.size > 0) {
|
||||||
|
this._clearSelection();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Public API ───────────────────────────────────────────────────
|
// ── Public API ───────────────────────────────────────────────────
|
||||||
@@ -41,6 +52,11 @@ class Sidebar {
|
|||||||
this.el.innerHTML = '';
|
this.el.innerHTML = '';
|
||||||
|
|
||||||
const profiles = this._filteredProfiles();
|
const profiles = this._filteredProfiles();
|
||||||
|
this._syncSelection(profiles);
|
||||||
|
|
||||||
|
if (this.selectedProfiles.size > 0) {
|
||||||
|
this.el.appendChild(this._renderBulkActions());
|
||||||
|
}
|
||||||
|
|
||||||
// Favourites
|
// Favourites
|
||||||
const favourites = profiles.filter((p) => p.favourite);
|
const favourites = profiles.filter((p) => p.favourite);
|
||||||
@@ -178,6 +194,10 @@ class Sidebar {
|
|||||||
dataset: { profileId: profile.id },
|
dataset: { profileId: profile.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (this.selectedProfiles.has(profile.id)) {
|
||||||
|
item.classList.add('sidebar-item-selected');
|
||||||
|
}
|
||||||
|
|
||||||
// Highlight if this profile's session is active
|
// Highlight if this profile's session is active
|
||||||
const activeSession = this.app.state.activeSessionId
|
const activeSession = this.app.state.activeSessionId
|
||||||
? this.app.state.getSession(this.app.state.activeSessionId)
|
? this.app.state.getSession(this.app.state.activeSessionId)
|
||||||
@@ -227,8 +247,21 @@ class Sidebar {
|
|||||||
item.appendChild(star);
|
item.appendChild(star);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click → connect
|
// Prevent mousedown text selection in WKWebView
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('mousedown', (e) => e.preventDefault());
|
||||||
|
|
||||||
|
// Click → connect / multi-select
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
const multi = e.metaKey || e.ctrlKey;
|
||||||
|
if (multi) {
|
||||||
|
e.preventDefault();
|
||||||
|
this._toggleSelection(profile.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.selectedProfiles.size > 0) {
|
||||||
|
this._clearSelection();
|
||||||
|
}
|
||||||
this.app.connectProfile(profile.id);
|
this.app.connectProfile(profile.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -406,6 +439,285 @@ class Sidebar {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Multi-select + bulk actions ─────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes selections that are no longer visible in the filtered list.
|
||||||
|
* @param {Array<Object>} profiles
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_syncSelection(profiles) {
|
||||||
|
const valid = new Set(profiles.map((p) => p.id));
|
||||||
|
for (const id of [...this.selectedProfiles]) {
|
||||||
|
if (!valid.has(id)) this.selectedProfiles.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
_toggleSelection(profileId) {
|
||||||
|
if (this.selectedProfiles.has(profileId)) {
|
||||||
|
this.selectedProfiles.delete(profileId);
|
||||||
|
} else {
|
||||||
|
this.selectedProfiles.add(profileId);
|
||||||
|
}
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
_clearSelection() {
|
||||||
|
if (this.selectedProfiles.size === 0) return;
|
||||||
|
this.selectedProfiles.clear();
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
_getSelectedProfiles() {
|
||||||
|
return this.app.state.profiles.filter((p) => this.selectedProfiles.has(p.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
_renderBulkActions() {
|
||||||
|
const bar = createElement('div', 'sidebar-bulk-actions');
|
||||||
|
const count = createElement('span', 'sidebar-bulk-count', {
|
||||||
|
textContent: `${this.selectedProfiles.size} selected`,
|
||||||
|
});
|
||||||
|
bar.appendChild(count);
|
||||||
|
|
||||||
|
const actions = createElement('div', 'sidebar-bulk-buttons');
|
||||||
|
const makeBtn = (label, handler, className) => {
|
||||||
|
const btn = createElement('button', `btn btn-xs ${className || ''}`.trim(), {
|
||||||
|
textContent: label,
|
||||||
|
});
|
||||||
|
btn.addEventListener('click', handler);
|
||||||
|
actions.appendChild(btn);
|
||||||
|
};
|
||||||
|
|
||||||
|
makeBtn('Connect', () => this._bulkConnect());
|
||||||
|
makeBtn('Disconnect', () => this._bulkDisconnect(), 'btn-outline');
|
||||||
|
makeBtn('Tags', () => this._bulkEditTags(), 'btn-outline');
|
||||||
|
makeBtn('Move Group', () => this._bulkMoveGroup(), 'btn-outline');
|
||||||
|
makeBtn('Clear', () => this._clearSelection(), 'btn-ghost');
|
||||||
|
|
||||||
|
bar.appendChild(actions);
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
_bulkConnect() {
|
||||||
|
const profiles = this._getSelectedProfiles();
|
||||||
|
for (const p of profiles) {
|
||||||
|
this.app.connectProfile(p.id);
|
||||||
|
}
|
||||||
|
this._clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
async _bulkDisconnect() {
|
||||||
|
const sessions = this.app.state.getAllSessions()
|
||||||
|
.filter((s) => this.selectedProfiles.has(s.profileId));
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
this.app.notifications.info('No active sessions to disconnect');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await this._confirmBulkDisconnect(sessions.length);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
for (const s of sessions) {
|
||||||
|
await this.app.closeSessionImmediate(s.id);
|
||||||
|
}
|
||||||
|
this._clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
async _bulkEditTags() {
|
||||||
|
const result = await this._promptTagEdit();
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
const profiles = this._getSelectedProfiles();
|
||||||
|
for (const p of profiles) {
|
||||||
|
const updated = JSON.parse(JSON.stringify(p));
|
||||||
|
const existing = updated.tags || [];
|
||||||
|
if (result.mode === 'replace') {
|
||||||
|
updated.tags = result.tags;
|
||||||
|
} else {
|
||||||
|
const merged = new Set([...existing, ...result.tags]);
|
||||||
|
updated.tags = [...merged];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = await window.go.api.Service.UpdateProfile(updated);
|
||||||
|
this.app.state.updateProfile(saved);
|
||||||
|
} catch (err) {
|
||||||
|
this.app.notifications.error('Failed to update tags: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
this._clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
async _bulkMoveGroup() {
|
||||||
|
const folder = await this._promptGroupMove();
|
||||||
|
if (folder === null) return;
|
||||||
|
|
||||||
|
const profiles = this._getSelectedProfiles();
|
||||||
|
for (const p of profiles) {
|
||||||
|
const updated = JSON.parse(JSON.stringify(p));
|
||||||
|
updated.folder = folder;
|
||||||
|
try {
|
||||||
|
const saved = await window.go.api.Service.UpdateProfile(updated);
|
||||||
|
this.app.state.updateProfile(saved);
|
||||||
|
} catch (err) {
|
||||||
|
this.app.notifications.error('Failed to move group: ' + err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
this._clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
_confirmBulkDisconnect(count) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const backdrop = createElement('div', 'modal-backdrop');
|
||||||
|
const modal = createElement('div', 'modal modal-sm');
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Disconnect Sessions</h3>
|
||||||
|
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p style="font-size:var(--text-sm);color:var(--text-secondary)">
|
||||||
|
Disconnect ${count} active ${count === 1 ? 'session' : 'sessions'}?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-sm confirm-cancel">Cancel</button>
|
||||||
|
<button class="btn btn-sm btn-danger confirm-ok">Disconnect</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
backdrop.appendChild(modal);
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
backdrop.classList.add('is-visible');
|
||||||
|
modal.classList.add('is-visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = (val) => {
|
||||||
|
backdrop.classList.remove('is-visible');
|
||||||
|
modal.classList.remove('is-visible');
|
||||||
|
setTimeout(() => backdrop.remove(), 200);
|
||||||
|
resolve(val);
|
||||||
|
};
|
||||||
|
modal.querySelector('.modal-close').addEventListener('click', () => close(false));
|
||||||
|
modal.querySelector('.confirm-cancel').addEventListener('click', () => close(false));
|
||||||
|
modal.querySelector('.confirm-ok').addEventListener('click', () => close(true));
|
||||||
|
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(false); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
_promptTagEdit() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const backdrop = createElement('div', 'modal-backdrop');
|
||||||
|
const modal = createElement('div', 'modal modal-sm');
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Edit Tags</h3>
|
||||||
|
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Tags (comma separated)</label>
|
||||||
|
<input class="form-input tag-input" type="text" placeholder="prod, linux, db" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-sm confirm-cancel">Cancel</button>
|
||||||
|
<button class="btn btn-sm btn-outline confirm-add">Add Tags</button>
|
||||||
|
<button class="btn btn-sm btn-primary confirm-replace">Replace Tags</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
backdrop.appendChild(modal);
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
backdrop.classList.add('is-visible');
|
||||||
|
modal.classList.add('is-visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = modal.querySelector('.tag-input');
|
||||||
|
const parseTags = () => {
|
||||||
|
const raw = (input.value || '').split(',');
|
||||||
|
return raw.map((t) => t.trim()).filter((t) => t.length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = (val) => {
|
||||||
|
backdrop.classList.remove('is-visible');
|
||||||
|
modal.classList.remove('is-visible');
|
||||||
|
setTimeout(() => backdrop.remove(), 200);
|
||||||
|
resolve(val);
|
||||||
|
};
|
||||||
|
modal.querySelector('.modal-close').addEventListener('click', () => close(null));
|
||||||
|
modal.querySelector('.confirm-cancel').addEventListener('click', () => close(null));
|
||||||
|
modal.querySelector('.confirm-add').addEventListener('click', () => {
|
||||||
|
close({ mode: 'add', tags: parseTags() });
|
||||||
|
});
|
||||||
|
modal.querySelector('.confirm-replace').addEventListener('click', () => {
|
||||||
|
close({ mode: 'replace', tags: parseTags() });
|
||||||
|
});
|
||||||
|
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(null); });
|
||||||
|
setTimeout(() => input.focus(), 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
_promptGroupMove() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const backdrop = createElement('div', 'modal-backdrop');
|
||||||
|
const modal = createElement('div', 'modal modal-sm');
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Move to Group</h3>
|
||||||
|
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Group name</label>
|
||||||
|
<input class="form-input group-input" type="text" placeholder="e.g. Production" />
|
||||||
|
<p style="font-size:var(--text-xs);color:var(--text-secondary);margin-top:6px;">
|
||||||
|
Leave blank to remove the group.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-sm confirm-cancel">Cancel</button>
|
||||||
|
<button class="btn btn-sm btn-primary confirm-ok">Move</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
backdrop.appendChild(modal);
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
backdrop.classList.add('is-visible');
|
||||||
|
modal.classList.add('is-visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = modal.querySelector('.group-input');
|
||||||
|
const close = (val) => {
|
||||||
|
backdrop.classList.remove('is-visible');
|
||||||
|
modal.classList.remove('is-visible');
|
||||||
|
setTimeout(() => backdrop.remove(), 200);
|
||||||
|
resolve(val);
|
||||||
|
};
|
||||||
|
modal.querySelector('.modal-close').addEventListener('click', () => close(null));
|
||||||
|
modal.querySelector('.confirm-cancel').addEventListener('click', () => close(null));
|
||||||
|
modal.querySelector('.confirm-ok').addEventListener('click', () => close(input.value.trim()));
|
||||||
|
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(null); });
|
||||||
|
setTimeout(() => input.focus(), 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Filtering ────────────────────────────────────────────────────
|
// ── Filtering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+85
-50
@@ -33,13 +33,16 @@ class TerminalManager {
|
|||||||
* @returns {Terminal} The xterm.js Terminal instance.
|
* @returns {Terminal} The xterm.js Terminal instance.
|
||||||
*/
|
*/
|
||||||
createTerminal(sessionId, containerId, profile) {
|
createTerminal(sessionId, containerId, profile) {
|
||||||
const themeName = (this.app.state.preferences || {}).terminalTheme || 'charcoal';
|
const themeName =
|
||||||
|
(this.app.state.preferences || {}).terminalTheme || "charcoal";
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
theme: window.NexusTerminal.getTerminalTheme(themeName),
|
theme: window.NexusTerminal.getTerminalTheme(themeName),
|
||||||
fontFamily: profile.terminalFontFamily || 'JetBrains Mono, Menlo, Monaco, Consolas, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace',
|
fontFamily:
|
||||||
|
profile.terminalFontFamily ||
|
||||||
|
'JetBrains Mono, Menlo, Monaco, Consolas, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", monospace',
|
||||||
fontSize: profile.terminalFontSize || 14,
|
fontSize: profile.terminalFontSize || 14,
|
||||||
scrollback: profile.terminalScrollback || 10000,
|
scrollback: profile.terminalScrollback || 10000,
|
||||||
cursorStyle: profile.terminalCursorStyle || 'block',
|
cursorStyle: profile.terminalCursorStyle || "block",
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
allowProposedApi: true,
|
allowProposedApi: true,
|
||||||
convertEol: false,
|
convertEol: false,
|
||||||
@@ -58,7 +61,7 @@ class TerminalManager {
|
|||||||
try {
|
try {
|
||||||
unicode11Addon = new Unicode11Addon.Unicode11Addon();
|
unicode11Addon = new Unicode11Addon.Unicode11Addon();
|
||||||
term.loadAddon(unicode11Addon);
|
term.loadAddon(unicode11Addon);
|
||||||
term.unicode.activeVersion = '11';
|
term.unicode.activeVersion = "11";
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Unicode 11 addon is optional; fall back to default unicode handling.
|
// Unicode 11 addon is optional; fall back to default unicode handling.
|
||||||
}
|
}
|
||||||
@@ -79,9 +82,9 @@ class TerminalManager {
|
|||||||
|
|
||||||
let suppressPasteEvent = false;
|
let suppressPasteEvent = false;
|
||||||
const attachPasteGuard = () => {
|
const attachPasteGuard = () => {
|
||||||
const inputEl = term.textarea || container.querySelector('textarea');
|
const inputEl = term.textarea || container.querySelector("textarea");
|
||||||
if (!inputEl) return;
|
if (!inputEl) return;
|
||||||
inputEl.addEventListener('paste', (e) => {
|
inputEl.addEventListener("paste", (e) => {
|
||||||
if (!suppressPasteEvent) return;
|
if (!suppressPasteEvent) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -91,9 +94,9 @@ class TerminalManager {
|
|||||||
attachPasteGuard();
|
attachPasteGuard();
|
||||||
|
|
||||||
// Right-click context menu on the terminal viewport
|
// Right-click context menu on the terminal viewport
|
||||||
const termEl = container.querySelector('.xterm');
|
const termEl = container.querySelector(".xterm");
|
||||||
if (termEl) {
|
if (termEl) {
|
||||||
termEl.addEventListener('contextmenu', (e) => {
|
termEl.addEventListener("contextmenu", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this._showTerminalContextMenu(sessionId, term, e.clientX, e.clientY);
|
this._showTerminalContextMenu(sessionId, term, e.clientX, e.clientY);
|
||||||
});
|
});
|
||||||
@@ -103,7 +106,13 @@ class TerminalManager {
|
|||||||
// true dimensions, then fit the terminal to fill the space.
|
// true dimensions, then fit the terminal to fill the space.
|
||||||
// eslint-disable-next-line no-unused-expressions
|
// eslint-disable-next-line no-unused-expressions
|
||||||
container.offsetHeight;
|
container.offsetHeight;
|
||||||
const doFit = () => { try { fitAddon.fit(); } catch (_) { /* noop */ } };
|
const doFit = () => {
|
||||||
|
try {
|
||||||
|
fitAddon.fit();
|
||||||
|
} catch (_) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
};
|
||||||
doFit();
|
doFit();
|
||||||
// Belt-and-braces: retry once after a paint in case the initial
|
// Belt-and-braces: retry once after a paint in case the initial
|
||||||
// reflow dimensions were still stale.
|
// reflow dimensions were still stale.
|
||||||
@@ -120,15 +129,17 @@ class TerminalManager {
|
|||||||
for (const [otherId] of this.terminals) {
|
for (const [otherId] of this.terminals) {
|
||||||
if (otherId === sessionId) continue;
|
if (otherId === sessionId) continue;
|
||||||
const otherSession = this.app.state.getSession(otherId);
|
const otherSession = this.app.state.getSession(otherId);
|
||||||
if (otherSession && otherSession.state === 'connected') {
|
if (otherSession && otherSession.state === "connected") {
|
||||||
try {
|
try {
|
||||||
window.go.api.Service.WriteSession(otherId, b64);
|
window.go.api.Service.WriteSession(otherId, b64);
|
||||||
} catch (_) { /* non-critical */ }
|
} catch (_) {
|
||||||
|
/* non-critical */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('WriteSession error:', err);
|
console.error("WriteSession error:", err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,7 +148,7 @@ class TerminalManager {
|
|||||||
const mod = e.ctrlKey || e.metaKey;
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
|
|
||||||
// Cmd/Ctrl+C — copy selected text (let xterm handle it normally if no selection)
|
// Cmd/Ctrl+C — copy selected text (let xterm handle it normally if no selection)
|
||||||
if (mod && e.key === 'c' && e.type === 'keydown') {
|
if (mod && e.key === "c" && e.type === "keydown") {
|
||||||
const selection = term.getSelection();
|
const selection = term.getSelection();
|
||||||
if (selection) {
|
if (selection) {
|
||||||
if (window.runtime && window.runtime.ClipboardSetText) {
|
if (window.runtime && window.runtime.ClipboardSetText) {
|
||||||
@@ -152,17 +163,25 @@ class TerminalManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cmd/Ctrl+V — paste from clipboard
|
// Cmd/Ctrl+V — paste from clipboard
|
||||||
if (mod && e.key === 'v' && e.type === 'keydown') {
|
if (mod && e.key === "v" && e.type === "keydown") {
|
||||||
suppressPasteEvent = true;
|
suppressPasteEvent = true;
|
||||||
setTimeout(() => { suppressPasteEvent = false; }, 250);
|
setTimeout(() => {
|
||||||
|
suppressPasteEvent = false;
|
||||||
|
}, 250);
|
||||||
if (window.runtime && window.runtime.ClipboardGetText) {
|
if (window.runtime && window.runtime.ClipboardGetText) {
|
||||||
window.runtime.ClipboardGetText().then(function(text) {
|
window.runtime
|
||||||
|
.ClipboardGetText()
|
||||||
|
.then(function (text) {
|
||||||
if (text) term.paste(text);
|
if (text) term.paste(text);
|
||||||
}).catch(function() {});
|
})
|
||||||
|
.catch(function () {});
|
||||||
} else {
|
} else {
|
||||||
navigator.clipboard.readText().then(function(text) {
|
navigator.clipboard
|
||||||
|
.readText()
|
||||||
|
.then(function (text) {
|
||||||
if (text) term.paste(text);
|
if (text) term.paste(text);
|
||||||
}).catch(function() {});
|
})
|
||||||
|
.catch(function () {});
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -175,7 +194,7 @@ class TerminalManager {
|
|||||||
try {
|
try {
|
||||||
window.go.api.Service.ResizeSession(sessionId, cols, rows);
|
window.go.api.Service.ResizeSession(sessionId, cols, rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('ResizeSession error:', err);
|
console.error("ResizeSession error:", err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -213,7 +232,7 @@ class TerminalManager {
|
|||||||
const raw = atob(base64Data);
|
const raw = atob(base64Data);
|
||||||
entry.term.write(raw);
|
entry.term.write(raw);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to decode session data:', err);
|
console.error("Failed to decode session data:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +257,7 @@ class TerminalManager {
|
|||||||
for (const [id, entry] of this.terminals) {
|
for (const [id, entry] of this.terminals) {
|
||||||
const panel = document.getElementById(`panel-${id}`);
|
const panel = document.getElementById(`panel-${id}`);
|
||||||
if (panel) {
|
if (panel) {
|
||||||
panel.style.display = id === sessionId ? 'flex' : 'none';
|
panel.style.display = id === sessionId ? "flex" : "none";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +265,11 @@ class TerminalManager {
|
|||||||
const entry = this.terminals.get(sessionId);
|
const entry = this.terminals.get(sessionId);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
try { entry.fitAddon.fit(); } catch (_) { /* noop */ }
|
try {
|
||||||
|
entry.fitAddon.fit();
|
||||||
|
} catch (_) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,7 +290,11 @@ class TerminalManager {
|
|||||||
const entry = this.terminals.get(sessionId);
|
const entry = this.terminals.get(sessionId);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
try { entry.fitAddon.fit(); } catch (_) { /* noop */ }
|
try {
|
||||||
|
entry.fitAddon.fit();
|
||||||
|
} catch (_) {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,10 +323,10 @@ class TerminalManager {
|
|||||||
caseSensitive: options.caseSensitive || false,
|
caseSensitive: options.caseSensitive || false,
|
||||||
wholeWord: options.wholeWord || false,
|
wholeWord: options.wholeWord || false,
|
||||||
decorations: {
|
decorations: {
|
||||||
matchBackground: '#ffa72633',
|
matchBackground: "#ffa72633",
|
||||||
activeMatchBackground: '#4ecdc466',
|
activeMatchBackground: "#4ecdc466",
|
||||||
matchBorder: '#ffa726',
|
matchBorder: "#ffa726",
|
||||||
activeMatchBorder: '#4ecdc4',
|
activeMatchBorder: "#4ecdc4",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -374,13 +401,13 @@ class TerminalManager {
|
|||||||
dismissContextMenus();
|
dismissContextMenus();
|
||||||
|
|
||||||
const hasSelection = term.hasSelection();
|
const hasSelection = term.hasSelection();
|
||||||
const menu = createElement('div', 'context-menu');
|
const menu = createElement("div", "context-menu");
|
||||||
menu.style.left = `${x}px`;
|
menu.style.left = `${x}px`;
|
||||||
menu.style.top = `${y}px`;
|
menu.style.top = `${y}px`;
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
label: 'Copy',
|
label: "Copy",
|
||||||
disabled: !hasSelection,
|
disabled: !hasSelection,
|
||||||
action: () => {
|
action: () => {
|
||||||
const text = term.getSelection();
|
const text = term.getSelection();
|
||||||
@@ -390,66 +417,72 @@ class TerminalManager {
|
|||||||
} else {
|
} else {
|
||||||
navigator.clipboard.writeText(text).catch(function () {});
|
navigator.clipboard.writeText(text).catch(function () {});
|
||||||
}
|
}
|
||||||
this.app.notifications.info('Copied to clipboard');
|
this.app.notifications.info("Copied to clipboard");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Paste',
|
label: "Paste",
|
||||||
action: () => {
|
action: () => {
|
||||||
if (window.runtime && window.runtime.ClipboardGetText) {
|
if (window.runtime && window.runtime.ClipboardGetText) {
|
||||||
window.runtime.ClipboardGetText().then((text) => {
|
window.runtime
|
||||||
|
.ClipboardGetText()
|
||||||
|
.then((text) => {
|
||||||
if (text) term.paste(text);
|
if (text) term.paste(text);
|
||||||
}).catch(function() {});
|
})
|
||||||
|
.catch(function () {});
|
||||||
} else {
|
} else {
|
||||||
navigator.clipboard.readText().then((text) => {
|
navigator.clipboard
|
||||||
|
.readText()
|
||||||
|
.then((text) => {
|
||||||
if (text) term.paste(text);
|
if (text) term.paste(text);
|
||||||
}).catch(function() {});
|
})
|
||||||
|
.catch(function () {});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
{
|
{
|
||||||
label: 'Select All',
|
label: "Select All",
|
||||||
action: () => term.selectAll(),
|
action: () => term.selectAll(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Find…',
|
label: "Find…",
|
||||||
action: () => this.app._openTerminalSearch(),
|
action: () => this.app._openTerminalSearch(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Send Snippet…',
|
label: "Send Snippet…",
|
||||||
action: () => this.app.snippets.open(),
|
action: () => this.app.snippets.open(),
|
||||||
},
|
},
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
{
|
{
|
||||||
label: 'Clear Terminal',
|
label: "Clear Terminal",
|
||||||
action: () => term.clear(),
|
action: () => term.clear(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Reset Terminal',
|
label: "Reset Terminal",
|
||||||
action: () => term.reset(),
|
action: () => term.reset(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const entry of items) {
|
for (const entry of items) {
|
||||||
if (entry.divider) {
|
if (entry.divider) {
|
||||||
menu.appendChild(createElement('div', 'context-menu-divider'));
|
menu.appendChild(createElement("div", "context-menu-divider"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const btn = createElement('button', 'context-menu-item', {
|
const btn = createElement("button", "context-menu-item", {
|
||||||
textContent: entry.label,
|
textContent: entry.label,
|
||||||
});
|
});
|
||||||
if (entry.disabled) {
|
if (entry.disabled) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.classList.add('context-menu-disabled');
|
btn.classList.add("context-menu-disabled");
|
||||||
}
|
}
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener("click", () => {
|
||||||
dismissContextMenus();
|
dismissContextMenus();
|
||||||
entry.action();
|
entry.action();
|
||||||
});
|
});
|
||||||
// Prevent mousedown from causing text deselection in WKWebView
|
// Prevent mousedown from causing text deselection in WKWebView
|
||||||
btn.addEventListener('mousedown', (e) => e.preventDefault());
|
btn.addEventListener("mousedown", (e) => e.preventDefault());
|
||||||
menu.appendChild(btn);
|
menu.appendChild(btn);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,8 +490,10 @@ class TerminalManager {
|
|||||||
|
|
||||||
// Keep within viewport
|
// Keep within viewport
|
||||||
const rect = menu.getBoundingClientRect();
|
const rect = menu.getBoundingClientRect();
|
||||||
if (rect.right > window.innerWidth) menu.style.left = `${window.innerWidth - rect.width - 8}px`;
|
if (rect.right > window.innerWidth)
|
||||||
if (rect.bottom > window.innerHeight) menu.style.top = `${window.innerHeight - rect.height - 8}px`;
|
menu.style.left = `${window.innerWidth - rect.width - 8}px`;
|
||||||
|
if (rect.bottom > window.innerHeight)
|
||||||
|
menu.style.top = `${window.innerHeight - rect.height - 8}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -467,12 +502,12 @@ class TerminalManager {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_buildTheme() {
|
_buildTheme() {
|
||||||
const themeName = (this.app.state.preferences || {}).terminalTheme || 'charcoal';
|
const themeName =
|
||||||
|
(this.app.state.preferences || {}).terminalTheme || "charcoal";
|
||||||
return window.NexusTerminal.getTerminalTheme(themeName);
|
return window.NexusTerminal.getTerminalTheme(themeName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.NexusTerminal = window.NexusTerminal || {};
|
window.NexusTerminal = window.NexusTerminal || {};
|
||||||
window.NexusTerminal.TerminalManager = TerminalManager;
|
window.NexusTerminal.TerminalManager = TerminalManager;
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
Vendored
+2
@@ -33,6 +33,8 @@ export function DeleteTOTPSecret(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function DisconnectSession(arg1:string):Promise<void>;
|
export function DisconnectSession(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function DownloadGitHubRepoArchive(arg1:string,arg2:string,arg3:string):Promise<string>;
|
||||||
|
|
||||||
export function DuplicateProfile(arg1:string):Promise<models.Profile>;
|
export function DuplicateProfile(arg1:string):Promise<models.Profile>;
|
||||||
|
|
||||||
export function ExportProfiles(arg1:boolean):Promise<string>;
|
export function ExportProfiles(arg1:boolean):Promise<string>;
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export function DisconnectSession(arg1) {
|
|||||||
return window['go']['api']['Service']['DisconnectSession'](arg1);
|
return window['go']['api']['Service']['DisconnectSession'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadGitHubRepoArchive(arg1, arg2, arg3) {
|
||||||
|
return window['go']['api']['Service']['DownloadGitHubRepoArchive'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function DuplicateProfile(arg1) {
|
export function DuplicateProfile(arg1) {
|
||||||
return window['go']['api']['Service']['DuplicateProfile'](arg1);
|
return window['go']['api']['Service']['DuplicateProfile'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ export namespace models {
|
|||||||
owner: string;
|
owner: string;
|
||||||
description: string;
|
description: string;
|
||||||
htmlURL: string;
|
htmlURL: string;
|
||||||
|
defaultBranch: string;
|
||||||
|
latestCommitter: string;
|
||||||
|
latestCommitAt: string;
|
||||||
private: boolean;
|
private: boolean;
|
||||||
fork: boolean;
|
fork: boolean;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
@@ -114,6 +117,9 @@ export namespace models {
|
|||||||
this.owner = source["owner"];
|
this.owner = source["owner"];
|
||||||
this.description = source["description"];
|
this.description = source["description"];
|
||||||
this.htmlURL = source["htmlURL"];
|
this.htmlURL = source["htmlURL"];
|
||||||
|
this.defaultBranch = source["defaultBranch"];
|
||||||
|
this.latestCommitter = source["latestCommitter"];
|
||||||
|
this.latestCommitAt = source["latestCommitAt"];
|
||||||
this.private = source["private"];
|
this.private = source["private"];
|
||||||
this.fork = source["fork"];
|
this.fork = source["fork"];
|
||||||
this.archived = source["archived"];
|
this.archived = source["archived"];
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -748,6 +749,46 @@ func (s *Service) RefreshGitHubRepos(sessionID string) ([]models.GitHubRepo, err
|
|||||||
return s.sessions.FetchGitHubRepos(sessionID)
|
return s.sessions.FetchGitHubRepos(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DownloadGitHubRepoArchive downloads a repository zip archive to a local file.
|
||||||
|
func (s *Service) DownloadGitHubRepoArchive(sessionID string, fullName string, ref string) (string, error) {
|
||||||
|
if s.ctx == nil {
|
||||||
|
return "", fmt.Errorf("application context not set")
|
||||||
|
}
|
||||||
|
if fullName == "" {
|
||||||
|
return "", fmt.Errorf("repository name cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(fullName, "/")
|
||||||
|
repoName := parts[len(parts)-1]
|
||||||
|
defaultName := repoName + ".zip"
|
||||||
|
if ref != "" {
|
||||||
|
safeRef := strings.ReplaceAll(ref, "/", "-")
|
||||||
|
defaultName = fmt.Sprintf("%s-%s.zip", repoName, safeRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{
|
||||||
|
Title: "Download Repository",
|
||||||
|
DefaultFilename: defaultName,
|
||||||
|
Filters: []wailsRuntime.FileFilter{
|
||||||
|
{DisplayName: "Zip Archive (*.zip)", Pattern: "*.zip"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("save dialog failed: %w", err)
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
return "", fmt.Errorf("download cancelled")
|
||||||
|
}
|
||||||
|
if filepath.Ext(path) != ".zip" {
|
||||||
|
path += ".zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.sessions.DownloadGitHubArchive(sessionID, fullName, ref, path); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Port forwarding
|
// Port forwarding
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/andydixon/nexus/internal/models"
|
"github.com/andydixon/nexus/internal/models"
|
||||||
@@ -42,6 +45,7 @@ type apiRepo struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
Private bool `json:"private"`
|
Private bool `json:"private"`
|
||||||
Fork bool `json:"fork"`
|
Fork bool `json:"fork"`
|
||||||
Archived bool `json:"archived"`
|
Archived bool `json:"archived"`
|
||||||
@@ -58,6 +62,15 @@ type apiRepo struct {
|
|||||||
// paginating through the API. Results are sorted by pushed_at descending
|
// paginating through the API. Results are sorted by pushed_at descending
|
||||||
// (most recently active first).
|
// (most recently active first).
|
||||||
func (c *Client) FetchRepos(ctx context.Context) ([]models.GitHubRepo, error) {
|
func (c *Client) FetchRepos(ctx context.Context) ([]models.GitHubRepo, error) {
|
||||||
|
return c.fetchRepos(ctx, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchReposWithProgress fetches repositories and reports enrichment progress.
|
||||||
|
func (c *Client) FetchReposWithProgress(ctx context.Context, progress func(done int, total int)) ([]models.GitHubRepo, error) {
|
||||||
|
return c.fetchRepos(ctx, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) fetchRepos(ctx context.Context, progress func(done int, total int)) ([]models.GitHubRepo, error) {
|
||||||
var all []apiRepo
|
var all []apiRepo
|
||||||
|
|
||||||
page := 1
|
page := 1
|
||||||
@@ -120,6 +133,7 @@ func (c *Client) FetchRepos(ctx context.Context) ([]models.GitHubRepo, error) {
|
|||||||
Owner: r.Owner.Login,
|
Owner: r.Owner.Login,
|
||||||
Description: r.Description,
|
Description: r.Description,
|
||||||
HTMLURL: r.HTMLURL,
|
HTMLURL: r.HTMLURL,
|
||||||
|
DefaultBranch: r.DefaultBranch,
|
||||||
Private: r.Private,
|
Private: r.Private,
|
||||||
Fork: r.Fork,
|
Fork: r.Fork,
|
||||||
Archived: r.Archived,
|
Archived: r.Archived,
|
||||||
@@ -129,9 +143,149 @@ func (c *Client) FetchRepos(ctx context.Context) ([]models.GitHubRepo, error) {
|
|||||||
UpdatedAt: r.UpdatedAt.Format(time.RFC3339),
|
UpdatedAt: r.UpdatedAt.Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if progress != nil {
|
||||||
|
progress(0, len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich with latest commit metadata (best-effort).
|
||||||
|
sem := make(chan struct{}, 5)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var done int32
|
||||||
|
for i := range result {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
|
||||||
|
ref := result[idx].DefaultBranch
|
||||||
|
if ref == "" {
|
||||||
|
ref = "HEAD"
|
||||||
|
}
|
||||||
|
committer, committedAt, err := c.FetchLatestCommit(ctx, result[idx].FullName, ref)
|
||||||
|
if err != nil {
|
||||||
|
if progress != nil {
|
||||||
|
current := int(atomic.AddInt32(&done, 1))
|
||||||
|
progress(current, len(result))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result[idx].LatestCommitter = committer
|
||||||
|
result[idx].LatestCommitAt = committedAt
|
||||||
|
if progress != nil {
|
||||||
|
current := int(atomic.AddInt32(&done, 1))
|
||||||
|
progress(current, len(result))
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FetchLatestCommit returns the committer name and commit time for a repo/ref.
|
||||||
|
func (c *Client) FetchLatestCommit(ctx context.Context, fullName string, ref string) (string, string, error) {
|
||||||
|
url := fmt.Sprintf("%s/repos/%s/commits/%s", c.baseURL, fullName, ref)
|
||||||
|
if ref == "" {
|
||||||
|
url = fmt.Sprintf("%s/repos/%s/commits/HEAD", c.baseURL, fullName)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("building request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
req.Header.Set("User-Agent", "NexusTerminal/1.0")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("fetching commit: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||||
|
return "", "", fmt.Errorf("authentication failed (HTTP %d)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", "", fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Commit struct {
|
||||||
|
Committer struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
} `json:"committer"`
|
||||||
|
Author struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
} `json:"author"`
|
||||||
|
} `json:"commit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
return "", "", fmt.Errorf("decoding commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := payload.Commit.Committer.Name
|
||||||
|
date := payload.Commit.Committer.Date
|
||||||
|
if name == "" {
|
||||||
|
name = payload.Commit.Author.Name
|
||||||
|
date = payload.Commit.Author.Date
|
||||||
|
}
|
||||||
|
if name == "" || date.IsZero() {
|
||||||
|
return "", "", fmt.Errorf("commit metadata missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
return name, date.Format(time.RFC3339), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadRepoArchive downloads a repository zip archive to the given path.
|
||||||
|
func (c *Client) DownloadRepoArchive(ctx context.Context, fullName string, ref string, destPath string) error {
|
||||||
|
if fullName == "" {
|
||||||
|
return fmt.Errorf("repository name cannot be empty")
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("%s/repos/%s/zipball", c.baseURL, fullName)
|
||||||
|
if ref != "" {
|
||||||
|
url = url + "/" + ref
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("building request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
req.Header.Set("User-Agent", "NexusTerminal/1.0")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("downloading archive: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||||
|
return fmt.Errorf("authentication failed (HTTP %d) — check your access token", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(file, resp.Body); err != nil {
|
||||||
|
return fmt.Errorf("writing archive: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ValidateToken makes a lightweight call to /user to confirm the token works.
|
// ValidateToken makes a lightweight call to /user to confirm the token works.
|
||||||
func (c *Client) ValidateToken(ctx context.Context) (string, error) {
|
func (c *Client) ValidateToken(ctx context.Context) (string, error) {
|
||||||
url := c.baseURL + "/user"
|
url := c.baseURL + "/user"
|
||||||
|
|||||||
@@ -199,6 +199,9 @@ type GitHubRepo struct {
|
|||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
HTMLURL string `json:"htmlURL"`
|
HTMLURL string `json:"htmlURL"`
|
||||||
|
DefaultBranch string `json:"defaultBranch"`
|
||||||
|
LatestCommitter string `json:"latestCommitter"`
|
||||||
|
LatestCommitAt string `json:"latestCommitAt"`
|
||||||
Private bool `json:"private"`
|
Private bool `json:"private"`
|
||||||
Fork bool `json:"fork"`
|
Fork bool `json:"fork"`
|
||||||
Archived bool `json:"archived"`
|
Archived bool `json:"archived"`
|
||||||
|
|||||||
@@ -702,14 +702,41 @@ func (m *Manager) connectGitHub(sess *Session, getSecret func(prompt string) (st
|
|||||||
|
|
||||||
// Validate the token.
|
// Validate the token.
|
||||||
login, err := client.ValidateToken(context.Background())
|
login, err := client.ValidateToken(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
if m.isGitHubAuthError(err) && getSecret != nil {
|
||||||
|
retryToken, retryErr := m.promptAndStoreSecret(
|
||||||
|
profile.ID,
|
||||||
|
"github-token",
|
||||||
|
ghConf.SecretMode,
|
||||||
|
getSecret,
|
||||||
|
"GitHub token did not work. Please enter a valid token:",
|
||||||
|
)
|
||||||
|
if retryErr == nil && retryToken != "" {
|
||||||
|
client = githubclient.New(ghConf.BaseURL, retryToken)
|
||||||
|
login, err = client.ValidateToken(context.Background())
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("GitHub authentication failed: %w", err)
|
return fmt.Errorf("GitHub authentication failed: %w", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sess.githubClient = client
|
sess.githubClient = client
|
||||||
|
|
||||||
// Perform the initial fetch.
|
// Perform the initial fetch.
|
||||||
repos, err := client.FetchRepos(context.Background())
|
m.emitEvent("session:github:status", map[string]interface{}{
|
||||||
|
"sessionID": sess.Info.ID,
|
||||||
|
"status": "Loading repositories...",
|
||||||
|
})
|
||||||
|
repos, err := client.FetchReposWithProgress(context.Background(), func(done int, total int) {
|
||||||
|
if total <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.emitEvent("session:github:status", map[string]interface{}{
|
||||||
|
"sessionID": sess.Info.ID,
|
||||||
|
"status": fmt.Sprintf("Loading commit info (%d/%d)", done, total),
|
||||||
|
})
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("fetching repositories: %w", err)
|
return fmt.Errorf("fetching repositories: %w", err)
|
||||||
}
|
}
|
||||||
@@ -739,7 +766,19 @@ func (m *Manager) connectGitHub(sess *Session, getSecret func(prompt string) (st
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
updated, fetchErr := client.FetchRepos(ctx)
|
m.emitEvent("session:github:status", map[string]interface{}{
|
||||||
|
"sessionID": sess.Info.ID,
|
||||||
|
"status": "Refreshing repositories...",
|
||||||
|
})
|
||||||
|
updated, fetchErr := client.FetchReposWithProgress(ctx, func(done int, total int) {
|
||||||
|
if total <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.emitEvent("session:github:status", map[string]interface{}{
|
||||||
|
"sessionID": sess.Info.ID,
|
||||||
|
"status": fmt.Sprintf("Loading commit info (%d/%d)", done, total),
|
||||||
|
})
|
||||||
|
})
|
||||||
if fetchErr != nil {
|
if fetchErr != nil {
|
||||||
m.emitEvent("session:github:error", map[string]interface{}{
|
m.emitEvent("session:github:error", map[string]interface{}{
|
||||||
"sessionID": sess.Info.ID,
|
"sessionID": sess.Info.ID,
|
||||||
@@ -785,6 +824,29 @@ func (m *Manager) FetchGitHubRepos(sessionID string) ([]models.GitHubRepo, error
|
|||||||
return repos, nil
|
return repos, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DownloadGitHubArchive downloads a repository zip archive for a GitHub session.
|
||||||
|
func (m *Manager) DownloadGitHubArchive(sessionID string, fullName string, ref string, destPath string) error {
|
||||||
|
m.mu.RLock()
|
||||||
|
sess, ok := m.sessions[sessionID]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("sessions: session %q not found", sessionID)
|
||||||
|
}
|
||||||
|
if sess.githubClient == nil {
|
||||||
|
return fmt.Errorf("sessions: session %q is not a GitHub connection", sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ref == "" {
|
||||||
|
ref = "HEAD"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sess.githubClient.DownloadRepoArchive(context.Background(), fullName, ref, destPath); err != nil {
|
||||||
|
return fmt.Errorf("downloading archive: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Session recording
|
// Session recording
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1179,6 +1241,47 @@ func (m *Manager) buildSecretCallback(profileID string, secretType string, mode
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// promptAndStoreSecret asks the user for a secret and stores it based on the mode.
|
||||||
|
func (m *Manager) promptAndStoreSecret(profileID string, secretType string, mode models.SecretMode, getSecret func(prompt string) (string, error), prompt string) (string, error) {
|
||||||
|
if getSecret == nil {
|
||||||
|
return "", fmt.Errorf("no %s available and no prompt callback provided", secretType)
|
||||||
|
}
|
||||||
|
val, err := getSecret(prompt)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if val == "" {
|
||||||
|
return "", fmt.Errorf("%s cannot be empty", secretType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == models.SecretModeSaved {
|
||||||
|
stored := false
|
||||||
|
secretKey := fmt.Sprintf("profile:%s:%s", profileID, secretType)
|
||||||
|
if m.keychainStore != nil {
|
||||||
|
if sErr := m.keychainStore.SetSecret(secretKey, val); sErr == nil {
|
||||||
|
stored = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !stored && m.secretStore != nil && m.secretStore.IsUnlocked() {
|
||||||
|
_ = m.secretStore.SetSecret(secretKey, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isGitHubAuthError checks whether an error indicates authentication failure.
|
||||||
|
func (m *Manager) isGitHubAuthError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(msg, "token validation failed") ||
|
||||||
|
strings.Contains(msg, "authentication failed") ||
|
||||||
|
strings.Contains(msg, "http 401") ||
|
||||||
|
strings.Contains(msg, "http 403")
|
||||||
|
}
|
||||||
|
|
||||||
// closeSession shuts down the protocol client for a session.
|
// closeSession shuts down the protocol client for a session.
|
||||||
func (m *Manager) closeSession(sess *Session) {
|
func (m *Manager) closeSession(sess *Session) {
|
||||||
// Cancel any in-progress reconnect backoff.
|
// Cancel any in-progress reconnect backoff.
|
||||||
|
|||||||
Reference in New Issue
Block a user