ssh-server-manager 0.2.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/PKG-INFO +11 -6
  2. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/README.md +10 -5
  3. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/pyproject.toml +1 -1
  4. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/scripts/ssh_server_manager/__init__.py +1 -2
  5. ssh_server_manager-0.4.0/scripts/ssh_server_manager/assets/ui/app.js +1 -0
  6. ssh_server_manager-0.4.0/scripts/ssh_server_manager/assets/ui/contexts.css +1 -0
  7. ssh_server_manager-0.4.0/scripts/ssh_server_manager/assets/ui/contexts.js +1 -0
  8. ssh_server_manager-0.4.0/scripts/ssh_server_manager/assets/ui/index.html +1 -0
  9. ssh_server_manager-0.4.0/scripts/ssh_server_manager/assets/ui/styles.css +1 -0
  10. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/scripts/ssh_server_manager/cli.py +31 -2
  11. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/scripts/ssh_server_manager/db.py +206 -9
  12. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/scripts/ssh_server_manager/ssh_runner.py +204 -1
  13. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/scripts/ssh_server_manager/validation.py +36 -1
  14. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/scripts/ssh_server_manager/webapp.py +181 -4
  15. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/scripts/ssh_server_manager.egg-info/PKG-INFO +11 -6
  16. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/scripts/ssh_server_manager.egg-info/SOURCES.txt +5 -1
  17. ssh_server_manager-0.4.0/tests/test_db.py +166 -0
  18. ssh_server_manager-0.4.0/tests/test_runner.py +201 -0
  19. ssh_server_manager-0.4.0/tests/test_ui_assets.py +145 -0
  20. ssh_server_manager-0.4.0/tests/test_webapp.py +268 -0
  21. ssh_server_manager-0.4.0/tests/test_website.py +70 -0
  22. ssh_server_manager-0.2.0/scripts/ssh_server_manager/assets/ui/app.js +0 -452
  23. ssh_server_manager-0.2.0/scripts/ssh_server_manager/assets/ui/index.html +0 -188
  24. ssh_server_manager-0.2.0/scripts/ssh_server_manager/assets/ui/styles.css +0 -104
  25. ssh_server_manager-0.2.0/tests/test_db.py +0 -58
  26. ssh_server_manager-0.2.0/tests/test_runner.py +0 -61
  27. ssh_server_manager-0.2.0/tests/test_webapp.py +0 -95
  28. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/scripts/ssh_server_manager/askpass.py +0 -0
  29. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/scripts/ssh_server_manager/auth.py +0 -0
  30. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/scripts/ssh_server_manager/importer.py +0 -0
  31. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/scripts/ssh_server_manager/paths.py +0 -0
  32. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/scripts/ssh_server_manager/service.py +0 -0
  33. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/scripts/ssh_server_manager/ssh_config.py +0 -0
  34. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/scripts/ssh_server_manager/vault.py +0 -0
  35. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/scripts/ssh_server_manager.egg-info/dependency_links.txt +0 -0
  36. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/scripts/ssh_server_manager.egg-info/entry_points.txt +0 -0
  37. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/scripts/ssh_server_manager.egg-info/requires.txt +0 -0
  38. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/scripts/ssh_server_manager.egg-info/top_level.txt +0 -0
  39. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/setup.cfg +0 -0
  40. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/tests/test_askpass.py +0 -0
  41. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/tests/test_auth.py +0 -0
  42. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/tests/test_importer.py +0 -0
  43. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/tests/test_service.py +0 -0
  44. {ssh_server_manager-0.2.0 → ssh_server_manager-0.4.0}/tests/test_ssh_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ssh-server-manager
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Local-first SSH host and credential manager for humans and AI agents — OS-keychain vault, loopback web UI, agent-safe by design
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://xiayh0107.github.io/servers-connect/
@@ -39,6 +39,7 @@ connect to your servers — with every password and passphrase stored in your
39
39
  operating system's native credential vault, never in a plaintext file.
40
40
 
41
41
  [![test](https://github.com/xiayh0107/servers-connect/actions/workflows/test.yml/badge.svg)](https://github.com/xiayh0107/servers-connect/actions/workflows/test.yml)
42
+ [![PyPI](https://img.shields.io/pypi/v/ssh-server-manager)](https://pypi.org/project/ssh-server-manager/)
42
43
  [![release](https://img.shields.io/github/v/release/xiayh0107/servers-connect)](https://github.com/xiayh0107/servers-connect/releases)
43
44
  [![license](https://img.shields.io/badge/license-MIT-green.svg)](../LICENSE)
44
45
 
@@ -102,8 +103,8 @@ browser with a one-time tokenized URL.
102
103
 
103
104
  ## Features
104
105
 
105
- - **Connection profiles** — alias, host, port, user, notes, ordered
106
- ProxyJump chains (with cycle detection).
106
+ - **Connection profiles** — alias, host, port, user, project/scenario tags,
107
+ notes, ordered ProxyJump chains (with cycle detection).
107
108
  - **Three credential kinds** — vault-backed passwords, private keys with
108
109
  optional vault-backed passphrases, and ssh-agent/OpenSSH defaults.
109
110
  Credentials are reusable across servers and protected against deletion
@@ -121,10 +122,14 @@ browser with a one-time tokenized URL.
121
122
  for ControlMaster connection sharing (macOS/Linux), and `--json` for
122
123
  machine-readable results.
123
124
  - **Local web UI** — manage servers and credentials, test connections,
124
- import config, and reveal a stored secret after passkey or master-password
125
- re-authentication.
125
+ create/rename/delete tags and bulk-assign searchable hosts, choose
126
+ system/light/dark themes, import config, browse remote directories read-only
127
+ over SFTP, copy file references for an agent, and reveal a stored secret
128
+ after passkey or master-password re-authentication. Connection status expires
129
+ instead of staying green.
126
130
  - **Diagnostics** — `serverctl doctor` checks ssh availability and version,
127
- vault backend safety, database and config paths, and Python dependencies.
131
+ SFTP availability, vault backend safety, database and config paths, and
132
+ Python dependencies.
128
133
 
129
134
  ## Platform support
130
135
 
@@ -6,6 +6,7 @@ connect to your servers — with every password and passphrase stored in your
6
6
  operating system's native credential vault, never in a plaintext file.
7
7
 
8
8
  [![test](https://github.com/xiayh0107/servers-connect/actions/workflows/test.yml/badge.svg)](https://github.com/xiayh0107/servers-connect/actions/workflows/test.yml)
9
+ [![PyPI](https://img.shields.io/pypi/v/ssh-server-manager)](https://pypi.org/project/ssh-server-manager/)
9
10
  [![release](https://img.shields.io/github/v/release/xiayh0107/servers-connect)](https://github.com/xiayh0107/servers-connect/releases)
10
11
  [![license](https://img.shields.io/badge/license-MIT-green.svg)](../LICENSE)
11
12
 
@@ -69,8 +70,8 @@ browser with a one-time tokenized URL.
69
70
 
70
71
  ## Features
71
72
 
72
- - **Connection profiles** — alias, host, port, user, notes, ordered
73
- ProxyJump chains (with cycle detection).
73
+ - **Connection profiles** — alias, host, port, user, project/scenario tags,
74
+ notes, ordered ProxyJump chains (with cycle detection).
74
75
  - **Three credential kinds** — vault-backed passwords, private keys with
75
76
  optional vault-backed passphrases, and ssh-agent/OpenSSH defaults.
76
77
  Credentials are reusable across servers and protected against deletion
@@ -88,10 +89,14 @@ browser with a one-time tokenized URL.
88
89
  for ControlMaster connection sharing (macOS/Linux), and `--json` for
89
90
  machine-readable results.
90
91
  - **Local web UI** — manage servers and credentials, test connections,
91
- import config, and reveal a stored secret after passkey or master-password
92
- re-authentication.
92
+ create/rename/delete tags and bulk-assign searchable hosts, choose
93
+ system/light/dark themes, import config, browse remote directories read-only
94
+ over SFTP, copy file references for an agent, and reveal a stored secret
95
+ after passkey or master-password re-authentication. Connection status expires
96
+ instead of staying green.
93
97
  - **Diagnostics** — `serverctl doctor` checks ssh availability and version,
94
- vault backend safety, database and config paths, and Python dependencies.
98
+ SFTP availability, vault backend safety, database and config paths, and
99
+ Python dependencies.
95
100
 
96
101
  ## Platform support
97
102
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ssh-server-manager"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "Local-first SSH host and credential manager for humans and AI agents — OS-keychain vault, loopback web UI, agent-safe by design"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -1,4 +1,3 @@
1
1
  """SSH Server Manager implementation."""
2
2
 
3
- __version__ = "0.2.0"
4
-
3
+ __version__ = "0.4.0"
@@ -0,0 +1 @@
1
+ const e="ssh-manager-theme",t="ssh-manager-host-context";function a(e,t){try{return localStorage.getItem(e)||t}catch(e){return t}}function s(e,t){try{localStorage.setItem(e,t)}catch(e){}}const n={csrf:"",servers:[],credentials:[],contexts:[],auth:{},view:"workspace",hostQuery:"",hostContext:a(t,"*"),theme:a(e,"system"),credentialQuery:"",credentialKind:"*",files:E()};let r,i,o,l=null,d=null,c=0,u=0,p=0;const h=e=>document.querySelector(e);function v(e,t=!1){const a=h("#toast");a.textContent=e,a.className=t?"show error":"show",clearTimeout(r),r=setTimeout(()=>{a.className=""},4500)}async function f(e,t={}){const a=t.method||"GET",s={...t.headers||{}};t.body&&"string"!=typeof t.body&&(s["Content-Type"]="application/json",t.body=JSON.stringify(t.body)),"GET"!==a&&(s["X-CSRF-Token"]=n.csrf);const r=await fetch(e,{...t,method:a,headers:s,credentials:"same-origin"}),i=(r.headers.get("content-type")||"").includes("json")?await r.json():await r.text();if(!r.ok){const e=i.message||i.detail||i.error||`Request failed (${r.status})`;throw new Error(e)}return i}function m(e){return String(e??"").replace(/[&<>'"]/g,e=>({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[e]))}function y(e){const t=Math.max(0,Math.floor(e/1e3));if(t<60)return"just now";const a=Math.floor(t/60);if(a<60)return`${a}m ago`;const s=Math.floor(a/60);return s<24?`${s}h ago`:`${Math.floor(s/24)}d ago`}function w(e){if(!e.last_test_status||!e.last_test_at)return{tone:"neutral",short:"Not checked",meta:"—",label:"Not checked",title:"No connection check has been run"};const t=Date.parse(e.last_test_at);if(!Number.isFinite(t))return{tone:"neutral",short:"Unknown",meta:"—",label:"Unknown",title:"The last check time is unavailable"};const a=Math.max(0,Date.now()-t),s=new Date(t).toLocaleString();if(a>12e4){const t="ok"===e.last_test_status?"reachable":"unreachable";return{tone:"stale",short:`Last ${t}`,meta:y(a),label:`Last ${t} ${y(a)}`,title:`Last checked ${s}`}}if("ok"===e.last_test_status){const t=null==e.last_test_latency_ms?"":` · ${e.last_test_latency_ms} ms`;return{tone:"ok",short:"Reachable",meta:null==e.last_test_latency_ms?y(a):`${e.last_test_latency_ms} ms`,label:`Reachable ${y(a)}${t}`,title:`Verified ${s}`}}return{tone:"failed",short:"Unreachable",meta:y(a),label:`Unavailable ${y(a)}`,title:`Failed ${s}${e.last_test_message?`: ${e.last_test_message}`:""}`}}function b(t,a=!1){const r=["system","light","dark","contrast"].includes(t)?t:"system";n.theme=r;const i="system"===r?window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light":r;document.documentElement.dataset.theme=i,h("#themeSelect")&&(h("#themeSelect").value=r),a&&s(e,r)}function g(e){return`tag:${String(e).toLocaleLowerCase()}`}function C(){return o||=import("/assets/contexts.js")}function $(){return{getState:()=>n,getVisibleServers:x,api:f,refresh:q,toast:v,setHostContext:L,contextKey:g,showView:P,openServer:K,serverStatus:w}}function k(e){if("*"===n.hostContext)return!0;const t=e.tags||[];return"-"===n.hostContext?0===t.length:t.some(e=>g(e)===n.hostContext)}function x(){return n.servers.filter(k)}function S(){let e=0;n.servers.forEach(t=>{(t.tags||[]).length||(e+=1)});const t=new Map(n.contexts.map(e=>[g(e.name),{label:e.name,count:e.count}])),a=new Set(["*",...t.keys()]);e&&a.add("-"),a.has(n.hostContext)||(n.hostContext="*");const s=[`<option value="*">All hosts (${n.servers.length})</option>`];e&&s.push(`<option value="-">Unassigned (${e})</option>`),[...t.entries()].sort((e,t)=>e[1].label.localeCompare(t[1].label,void 0,{sensitivity:"base"})).forEach(([e,t])=>s.push(`<option value="${m(e)}">${m(t.label)} (${t.count})</option>`)),h("#hostContextSelect").innerHTML=s.join(""),h("#hostContextSelect").value=n.hostContext;const r=[...n.contexts].sort((e,t)=>e.name.localeCompare(t.name,void 0,{sensitivity:"base"}));h("#sidebarContextList").innerHTML=r.map((e,t)=>{const a=g(e.name),s=a===n.hostContext;return`<button type="button" class="sidebar-context-row${s?" active":""}" data-sidebar-filter-context="${m(a)}" aria-pressed="${s}"><i class="tag-color-${t%6}" aria-hidden="true"></i><span>${m(e.name)}</span><b>${e.count}</b></button>`}).join(""),h("#emptySidebarContexts").hidden=r.length>0;const i=x();h("#fileServerSelect").innerHTML=['<option value="">Choose a host…</option>'].concat(i.map(e=>`<option value="${m(e.id)}">${m(e.alias)}</option>`)).join(""),h("#fileServerSelect").disabled=0===i.length,h("#fileServerSelect").value=n.files.serverId}function L(e){n.hostContext=e,s(t,e);const a=n.servers.find(e=>e.id===n.files.serverId);a&&!k(a)&&(c+=1,n.files=E()),S(),T(),h("#serverCount").textContent=x().length,h("#emptyServers").hidden=x().length>0,O(),o&&o.then(e=>e.syncContextUI($())),I()}function E(e=""){return{serverId:e,path:"",requestedPath:"~",parent:null,entries:[],total:0,truncated:!1,unparsed:0,latencyMs:null,showHidden:!1,query:"",sortBy:"name",sortDirection:"asc",visibleLimit:250,loading:!1,error:""}}const _={workspace:{eyebrow:"REMOTE WORKSPACE",title:"Workspace",description:"Browse and reference remote files without opening a shell."},servers:{eyebrow:"CONNECTION INVENTORY",title:"Connections",description:"Manage endpoints, credentials, and jump hosts."},contexts:{eyebrow:"HOST ORGANIZATION",title:"Tags",description:"Label hosts for fast filtering by project, environment, or role."},credentials:{eyebrow:"SECURE STORAGE",title:"Credentials",description:"Reusable authentication profiles stored securely."}};function P(e){_[e]&&(n.view=e,document.body.dataset.view=e,document.querySelectorAll("[data-view-panel]").forEach(t=>{t.hidden=t.dataset.viewPanel!==e}),document.querySelectorAll(".nav-item").forEach(t=>{const a=t.dataset.view===e;t.classList.toggle("active",a),a?t.setAttribute("aria-current","page"):t.removeAttribute("aria-current")}),h("#hostNavigation").hidden="contexts"===e,h("#contextFilterControl").hidden="workspace"!==e&&"servers"!==e,h("#serverHeaderActions").hidden="servers"!==e,h("#contextHeaderActions").hidden="contexts"!==e,h("#credentialHeaderActions").hidden="credentials"!==e,h("#workspaceHeaderActions").hidden="workspace"!==e,h("#viewEyebrow").textContent=_[e].eyebrow,h("#viewTitle").textContent=_[e].title,h("#viewDescription").textContent=_[e].description)}function T(){const e=n.hostQuery.trim().toLocaleLowerCase(),t=x().filter(t=>!e||[t.alias,t.hostname,t.username,t.notes,...t.tags||[]].some(t=>String(t||"").toLocaleLowerCase().includes(e)));h("#emptyWorkspaceHosts").hidden=t.length>0,h("#workspaceHostList").innerHTML=t.map(e=>{const t=w(e),a=e.id===n.files.serverId,s=e.tags||[],r=s.slice(0,2).map(e=>`<button type="button" class="host-tag" data-sidebar-filter-context="${m(g(e))}" title="Show ${m(e)} hosts">${m(e)}</button>`).join(""),i=s.length>2?`<span class="host-tag-more">+${s.length-2}</span>`:"";return`<div class="workspace-host-row ${a?"active":""}"><button type="button" class="workspace-host" data-id="${m(e.id)}" aria-pressed="${a}" title="${m(t.label)} — ${m(t.title)}"><span class="host-status-dot ${t.tone}" aria-hidden="true"></span><span class="host-copy"><strong>${m(e.alias)}</strong><small>${m(e.username)}@${m(e.hostname)}</small></span></button><button type="button" class="workspace-host-context${s.length?"":" empty"}" data-sidebar-contexts data-id="${m(e.id)}" aria-label="Edit tags for ${m(e.alias)}" aria-haspopup="dialog" aria-expanded="false" title="${m(s.length?`Edit tags: ${s.join(", ")}`:"Add tag")}">${s.length?"•••":"+"}</button><div class="workspace-host-tags">${r}${i}</div></div>`}).join("")}function B(e,t){const a="directory"===e.type;if(a!==("directory"===t.type))return a?-1:1;let s=0;return s="size"===n.files.sortBy?Number(e.size)-Number(t.size):"modified"===n.files.sortBy?String(e.modified).localeCompare(String(t.modified)):String(e.name).localeCompare(String(t.name),void 0,{numeric:!0,sensitivity:"base"}),"asc"===n.files.sortDirection?s:-s}function H(){const e=n.files.query.trim().toLocaleLowerCase();return n.files.entries.filter(e=>n.files.showHidden||!e.hidden).filter(t=>!e||t.name.toLocaleLowerCase().includes(e)).slice().sort(B)}function M(e){if(!e)return;const t=w(e);h("#fileHostStatus").className=`compact-status ${t.tone}`,h("#fileHostStatus").innerHTML=`<i></i>${m(t.label)}`,h("#fileHostStatus").title=t.title}function N(){T(),O(),o&&o.then(e=>e.syncContextUI($())),M(n.servers.find(e=>e.id===n.files.serverId))}function I(){const e=n.servers.find(e=>e.id===n.files.serverId),t=Boolean(e);if(h("#fileBrowserEmpty").hidden=t,h("#fileBrowserContent").hidden=!t,h("#emptyChooseHostButton").hidden=0===n.servers.length,h("#workspaceActiveActions").hidden=!t,h("#workspaceAddHeaderButton").hidden=t,h("#workspaceHeaderActions").hidden="workspace"!==n.view,!t)return;h("#fileConnectionLabel").innerHTML=`<strong>${m(e.alias)}<small>Active SSH host</small></strong><span>${m(e.username)}@${m(e.hostname)} · port ${e.port}</span>`,M(e),h("#fileShowHidden").checked=n.files.showHidden,h("#fileSearchInput").value=n.files.query,h("#fileHomeButton").disabled=n.files.loading,h("#fileUpButton").disabled=n.files.loading||!n.files.parent,h("#fileRefreshButton").disabled=n.files.loading||!n.files.path,h("#copyDirectoryReferenceButton").disabled=n.files.loading||!n.files.path,function(e){if(!e)return void(h("#fileBreadcrumbs").innerHTML='<span class="breadcrumb-current">Home</span>');const t=e.split("/").filter(Boolean),a=[{label:"/",path:"/"}];let s="";for(const e of t)s+=`/${e}`,a.push({label:e,path:s});h("#fileBreadcrumbs").innerHTML=a.map((e,t)=>`${t?'<span class="breadcrumb-separator" aria-hidden="true">/</span>':""}${t===a.length-1?`<span class="breadcrumb-current" aria-current="page">${m(e.label)}</span>`:`<button type="button" class="breadcrumb-button" data-path="${m(e.path)}">${m(e.label)}</button>`}`).join("")}(n.files.path),document.querySelectorAll(".sort-button").forEach(e=>{const t=e.dataset.sort===n.files.sortBy;e.classList.toggle("active",t),e.querySelector("span").textContent=t?"asc"===n.files.sortDirection?"↑":"↓":""});const a=Boolean(n.files.error);h("#fileBrowserError").hidden=!a,h("#fileBrowserErrorMessage").textContent=n.files.error,h("#fileLoading").hidden=!n.files.loading;const s=n.files.showHidden?0:n.files.entries.filter(e=>e.hidden).length,r=n.files.query.trim().toLocaleLowerCase(),i=H(),o=i.slice(0,n.files.visibleLimit),l=!n.files.loading&&!a&&o.length>0,d=Math.max(0,i.length-o.length);if(h("#fileTableWrap").hidden=!l,h("#fileListMore").hidden=n.files.loading||a||0===d,h("#fileListMoreStatus").textContent=`${o.length} of ${i.length} items shown`,h("#fileLoadMoreButton").textContent=`Show ${Math.min(250,d)} more`,h("#fileListEmpty").hidden=n.files.loading||a||i.length>0,h("#copyVisibleReferencesButton").disabled=n.files.loading||a||0===o.length,h("#fileRows").innerHTML=o.map(e=>{const t="directory"===e.type?`<button type="button" class="file-name-button open-directory" data-path="${m(e.path)}">${m(e.name)}</button>`:`<span class="file-name">${m(e.name)}</span>`;return`<tr>\n <td><div class="file-name-cell">${function(e){return`<span class="file-icon ${["directory","symlink"].includes(e)?e:"file"}" aria-hidden="true"></span>`}(e.type)}<div>${t}<span class="file-kind">${m(e.type)}</span></div></div></td>\n <td class="file-size">${function(e,t){if("directory"===t)return"—";const a=Number(e)||0;if(a<1024)return`${a} B`;const s=["KB","MB","GB","TB"];let n=a,r=-1;do{n/=1024,r+=1}while(n>=1024&&r<s.length-1);return`${n>=10?n.toFixed(0):n.toFixed(1)} ${s[r]}`}(e.size,e.type)}</td>\n <td class="file-modified-column subtle">${m(function(e){const t=String(e||"—").trim().split(/\s+/);return t.length<3?t.join(" "):`${t[0]} ${t[1]} · ${t.slice(2).join(" ")}`}(e.modified))}</td>\n <td class="file-permissions-column">${function(e){const t=String(e||"----------").padEnd(10,"-"),a=[t.slice(1,4),t.slice(4,7),t.slice(7,10)],s=a.map(e=>e.replace(/-/g,"").toUpperCase()||"—"),n=a.map(e=>["-"!==e[0]?"read":"","-"!==e[1]?"write":"","-"!==e[2]?"execute":""].filter(Boolean).join("/")||"none");return`<span class="permission-summary" title="${m(`Owner: ${n[0]} · Group: ${n[1]} · Others: ${n[2]} · ${t}`)}"><b>Owner ${s[0]}</b><small>Group ${s[1]} · Others ${s[2]}</small></span>`}(e.permissions)}</td>\n <td><button type="button" class="row-icon-button copy-file-reference" data-path="${m(e.path)}" aria-label="Copy ${m(e.name)} reference" title="Copy Agent reference"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="11" height="11" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg></button></td>\n </tr>`}).join(""),n.files.loading)h("#fileListStatus").textContent="Connecting over SFTP…";else if(a)h("#fileListStatus").textContent="Connection failed";else if(n.files.path){const e=[d?`${o.length} of ${i.length} items`:`${i.length} item${1===i.length?"":"s"}`];r&&e.push("filtered from "+(n.files.entries.length-s)),s&&e.push(`${s} hidden`),n.files.truncated&&e.push(`showing the first ${n.files.entries.length}`),n.files.unparsed&&e.push(`${n.files.unparsed} unsupported name${1===n.files.unparsed?"":"s"} skipped`),null!=n.files.latencyMs&&e.push(`${n.files.latencyMs} ms`),h("#fileListStatus").textContent=e.join(" · ")}else h("#fileListStatus").textContent="Ready to connect";const c=n.files.entries.length>0&&0===i.length;h("#fileListEmptyTitle").textContent=c?"No matching files":"This directory is empty",h("#fileListEmptyMessage").textContent=c?"Try another filter or include hidden files.":"There is nothing to display here."}async function D(e="~"){if(!n.files.serverId)return;const t=++c,a=e||"~";n.files.requestedPath=a,n.files.visibleLimit=250,n.files.loading=!0,n.files.error="",h("#filePathInput").value=a,I();try{const e=new URLSearchParams({path:a}),s=await f(`/api/servers/${n.files.serverId}/files?${e}`);if(t!==c)return;n.files.path=s.path,n.files.requestedPath=s.path,n.files.parent=s.parent,n.files.entries=s.entries,n.files.total=s.total,n.files.truncated=s.truncated,n.files.unparsed=s.unparsed,n.files.latencyMs=s.latency_ms;const r=n.servers.find(e=>e.id===n.files.serverId);r&&(r.last_test_at=s.connection_checked_at,r.last_test_status="ok",r.last_test_latency_ms=s.latency_ms,r.last_test_error_code=null,r.last_test_message="SFTP connection succeeded",T(),O()),h("#filePathInput").value=s.path}catch(e){if(t!==c)return;n.files.error=e.message||String(e),v(n.files.error,!0);try{await q()}catch(e){}}finally{t===c&&(n.files.loading=!1,I())}}async function A(e){if(P("workspace"),e&&e===n.files.serverId&&n.files.path)return T(),void I();c+=1,n.files=E(e),h("#fileServerSelect").value=e,h("#filePathInput").value="~",T(),I(),e&&await D("~")}async function j(e){const t=n.servers.find(e=>e.id===n.files.serverId);t&&e&&(await navigator.clipboard.writeText(`${t.alias}:${e}`),v("Remote path copied for the Agent"))}async function F(){const e=n.servers.find(e=>e.id===n.files.serverId),t=H().slice(0,n.files.visibleLimit);e&&t.length&&(await navigator.clipboard.writeText(t.map(t=>`${e.alias}:${t.path}`).join("\n")),v(`${t.length} file reference${1===t.length?"":"s"} copied`))}function O(){h("#serverRows").innerHTML=x().map(e=>{const t=e.proxy_jumps||[],a=t.length?t.join(" → "):"Direct connection",s=n.credentials.find(t=>t.id===e.credential_id),r=e.credential_label||"OpenSSH default",i=s?.kind||"agent";return`<tr data-server-id="${m(e.id)}">\n <td><div class="connection-name"><span class="host-status-dot ${w(e).tone}" aria-hidden="true"></span><div><span class="alias">${m(e.alias)}</span><span class="subtle">${m(e.notes||"No notes")}</span></div></div></td>\n <td><div class="endpoint-cell"><code>${m(e.username)}@${m(e.hostname)}</code><span><b>SSH</b> Port ${e.port}</span></div></td>\n <td>${function(e){return e?.length?`<div class="tag-list">${e.map(e=>`<button type="button" class="table-context-tag" data-context="${m(e)}">${m(e)}</button>`).join("")}<button type="button" class="table-context-edit" data-edit-contexts aria-label="Edit tags" aria-haspopup="dialog" aria-expanded="false">+</button></div>`:'<button type="button" class="table-context-empty" data-edit-contexts aria-haspopup="dialog" aria-expanded="false">+ Add tag</button>'}(e.tags)}</td>\n <td><div class="credential-cell"><i aria-hidden="true">✓</i><span><strong>${m(r)}</strong><small>${m(i)}</small></span></div></td>\n <td><div class="route-cell"><strong>${t.length?"ProxyJump":"—"}</strong><small>${m(a)}</small></div></td>\n <td>${function(e){const t=w(e);return`<span class="status-block" title="${m(t.title)}"><span class="status-pill ${t.tone}"><i aria-hidden="true"></i>${m(t.short)}</span><small>${m(t.meta)}</small></span>`}(e)}</td>\n <td><div class="table-actions connection-actions">\n <button class="button small browse-files" data-id="${m(e.id)}"><span aria-hidden="true">›_</span> Browse</button>\n <details class="row-menu"><summary aria-label="More actions for ${m(e.alias)}">•••</summary><div><button type="button" class="test-server" data-id="${m(e.id)}">Test connection</button><button type="button" class="copy-command" data-alias="${m(e.alias)}">Copy command</button><button type="button" class="edit-server" data-id="${m(e.id)}">Edit host</button><button type="button" class="delete-server danger" data-id="${m(e.id)}">Delete host</button></div></details>\n </div></td>\n </tr>`}).join("")}function R(){const e=n.credentialQuery.trim().toLocaleLowerCase(),t=n.credentials.filter(t=>("*"===n.credentialKind||t.kind===n.credentialKind)&&(!e||[t.label,t.kind,t.key_path].some(t=>String(t||"").toLocaleLowerCase().includes(e))));h("#credentialRows").innerHTML=t.map(e=>{const t="password"===e.kind?e.has_secret?"Password stored":"Missing password":"key"===e.kind?e.has_passphrase?"Passphrase stored":"No passphrase":"Uses local agent",a="agent"===e.kind||"password"===e.kind&&e.has_secret||"key"===e.kind&&e.key_path,s=e.has_secret||e.has_passphrase,r={password:"Password",key:"Key",agent:"OpenSSH"}[e.kind]||e.kind,i=e.key_path?`<div class="key-location"><code title="${m(e.key_path)}">${m(e.key_path)}</code><button class="endpoint-copy copy-key-path" data-path="${m(e.key_path)}" aria-label="Copy key path">Copy</button></div>`:'<span class="subtle">—</span>',o=function(e){const t=n.servers.filter(t=>t.credential_id===e.id&&t.last_test_at).map(e=>Date.parse(e.last_test_at)).filter(Number.isFinite);if(!t.length)return{label:"Never",title:"No connection check has used this credential"};const a=Math.max(...t);return{label:y(Date.now()-a),title:new Date(a).toLocaleString()}}(e);return`<tr><td><span class="alias">${m(e.label)}</span></td><td><span class="credential-kind">${m(r)}</span></td><td><span class="credential-ready ${a?"ready":"incomplete"}"><i></i>${a?"Ready":"Incomplete"}</span></td><td>${m(t)}</td><td>${i}</td><td><span class="last-checked" title="${m(o.title)}">${m(o.label)}</span></td><td><div class="table-actions">${s?`<button class="button small subtle-button reveal-credential" data-id="${m(e.id)}">Reveal</button>`:""}<button class="button secondary small edit-credential" data-id="${m(e.id)}">Edit</button><button class="row-icon-button danger delete-credential" data-id="${m(e.id)}" aria-label="Delete ${m(e.label)}" title="Delete credential"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5"/></svg></button></div></td></tr>`}).join(""),h("#emptyCredentials").hidden=t.length>0,h("#emptyCredentials").textContent=n.credentials.length?"No credentials match this search.":"No credential profiles configured."}async function q(){const e=await f("/api/bootstrap");n.csrf=e.csrf,n.servers=e.servers,n.credentials=e.credentials,n.contexts=e.contexts||[],n.auth=e.auth,h("#configPath").textContent=e.managed_config,h("#systemStatus").className="session-status ok",h("#systemStatus small").textContent="Ready on this device",function(){n.files.serverId&&!n.servers.some(e=>e.id===n.files.serverId)&&(c+=1,n.files=E()),S();const e=x();h("#serverCount").textContent=e.length,h("#credentialCount").textContent=n.credentials.length,h("#navServerCount").textContent=n.servers.length,h("#navContextCount").textContent=n.contexts.length,h("#navCredentialCount").textContent=n.credentials.length,h("#summaryTotalHosts").textContent=n.servers.length;const t=n.servers.filter(e=>"ok"===w(e).tone).length;h("#summaryReachableHosts").textContent=t,h("#summaryReachablePercent").textContent=n.servers.length?`${(t/n.servers.length*100).toFixed(1)}% recently verified`:"No hosts yet",h("#summaryCredentialCount").textContent=n.credentials.length,h("#summaryContextCount").textContent=n.contexts.length,h("#credentialProfileCount").textContent=n.credentials.length,h("#emptyServers").hidden=e.length>0,h("#emptyServers").textContent=n.servers.length?"No hosts with this tag.":"No managed servers yet. Import your SSH config or add one.",h("#emptyWorkspaceHosts").textContent=n.servers.length?"No hosts match this view.":"No managed hosts yet.",O(),R();const a=['<option value="">OpenSSH default / agent</option>'].concat(n.credentials.map(e=>`<option value="${m(e.id)}">${m(e.label)} (${m(e.kind)})</option>`));h("#serverCredential").innerHTML=a.join("");const s=[];s.push(`${n.auth.passkeys||0} passkey${1===n.auth.passkeys?"":"s"} enrolled`),s.push(n.auth.master_password_enrolled?"fallback master password enrolled":"no fallback master password"),h("#authDescription").textContent=s.join(" · "),h("#authStatusTitle").textContent=n.auth.passkeys>0?"Passkey active":n.auth.master_password_enrolled?"Fallback password active":"Protection setup required",h("#enrollPasskeyButton").disabled=!n.auth.webauthn_available||!window.PublicKeyCredential,h("#enrollMasterButton").disabled=n.auth.master_password_enrolled,T(),function(){const e=[...n.servers].sort((e,t)=>{const a=Date.parse(e.last_test_at||e.updated_at||e.created_at||0)||0;return(Date.parse(t.last_test_at||t.updated_at||t.created_at||0)||0)-a}).slice(0,3);document.querySelector(".recent-hosts-card").hidden=0===e.length,h("#recentHostRows").innerHTML=e.map(e=>{const t=w(e),a=e.tags?.[0];return`<button type="button" class="recent-host-row" data-id="${m(e.id)}"><span class="recent-host-icon" aria-hidden="true"></span><span class="recent-host-copy"><strong>${m(e.alias)}</strong><small>${m(e.username)}@${m(e.hostname)}</small></span>${a?`<span class="recent-host-tag">${m(a)}</span>`:""}<span class="recent-host-status ${t.tone}"><b><i></i>${m(t.short)}</b><small>${m(t.meta)}</small></span><span class="recent-host-arrow" aria-hidden="true">›</span></button>`}).join("")}(),I(),P(n.view),o&&o.then(e=>e.syncContextUI($()))}()}function K(e=null){h("#serverDialogTitle").textContent=e?"Edit server":"Add server",h("#serverId").value=e?.id||"",h("#serverAlias").value=e?.alias||"",h("#serverHostname").value=e?.hostname||"",h("#serverPort").value=e?.port||22,h("#serverUsername").value=e?.username||"",h("#serverCredential").value=e?.credential_id||"",h("#serverProxy").value=e?.proxy_jumps.join(", ")||"",h("#serverTags").value=(e?.tags||[]).join(", "),h("#serverNotes").value=e?.notes||"",V(),h("#serverDialog").showModal(),C().then(e=>e.enhanceTagPicker({input:h("#serverTags"),getContexts:()=>n.contexts,toast:v})).catch(e=>v(e.message||String(e),!0))}function U(){const e=h("#serverCredentialNewKind").value,t="password"===e,a="key"===e;h("#serverCredentialNewSecretField").hidden=!t,h("#serverCredentialNewKeyPathField").hidden=!a,h("#serverCredentialNewPassphraseField").hidden=!a,h("#serverCredentialNewSecret").required=t,h("#serverCredentialNewKeyPath").required=a}function V(){h("#serverCredentialComposer").hidden=!0,h("#serverCredentialNewLabel").value="",h("#serverCredentialNewKind").value="password",h("#serverCredentialNewSecret").value="",h("#serverCredentialNewKeyPath").value="",h("#serverCredentialNewPassphrase").value="",U()}function G(){h("#serverCredentialComposer").hidden=!1,U(),h("#serverCredentialNewLabel").focus()}async function Q(){const e=h("#serverCredentialNewKind").value,t={label:h("#serverCredentialNewLabel").value.trim(),kind:e};if(!t.label)throw new Error("Credential label is required");if("password"===e&&(t.secret=h("#serverCredentialNewSecret").value,!t.secret))throw new Error("Password is required");if("key"===e){if(t.key_path=h("#serverCredentialNewKeyPath").value.trim(),!t.key_path)throw new Error("Private-key path is required");h("#serverCredentialNewPassphrase").value&&(t.passphrase=h("#serverCredentialNewPassphrase").value)}const a=await f("/api/credentials",{method:"POST",body:t});await q(),h("#serverCredential").value=a.id,V(),v("Credential created and selected. Save the server to finish.")}function W(){const e=h("#credentialKind").value;h("#secretField").hidden="password"!==e,h("#keyPathField").hidden="key"!==e,h("#passphraseField").hidden="key"!==e,h("#clearPassphraseField").hidden="key"!==e||!h("#credentialId").value}function z(e=null){const t=Boolean(e);h("#credentialDialogTitle").textContent=t?"Edit credential":"Add credential",h("#credentialId").value=e?.id||"",h("#credentialLabel").value=e?.label||"",h("#credentialKind").value=e?.kind||"password",h("#credentialKind").disabled=t,h("#credentialSecret").value="",h("#credentialSecret").required=!t&&"password"===h("#credentialKind").value,h("#credentialKeyPath").value=e?.key_path||"",h("#credentialPassphrase").value="",h("#credentialClearPassphrase").checked=!1,h("#credentialEditHint").hidden=!t,W(),h("#credentialDialog").showModal()}async function J(){const e=await f("/api/import/preview",{method:"POST",body:{}}),t=e.items.filter(e=>"add"===e.action).length,a=e.items.filter(e=>"conflict"===e.action).length;if(!confirm(`Import ${t} new host(s)? ${a} conflict(s) will be skipped.`))return;const s=await f("/api/import/apply",{method:"POST",body:{overwrite:!1}});v(`Imported ${s.added.length} host(s); skipped ${s.skipped.length}`),await q()}async function X(){const e=h("#sidebarContextName").value.trim();if(!e)return h("#sidebarContextName").focus();await f("/api/contexts",{method:"POST",body:{name:e}}),h("#sidebarContextName").value="",h("#sidebarContextForm").hidden=!0,await q(),v(`Tag “${e}” created`)}function Y(){const e=n.servers.map(e=>({alias:e.alias,hostname:e.hostname,port:e.port,username:e.username,credential:e.credential_label||null,proxy_jumps:e.proxy_jumps||[],tags:e.tags||[],notes:e.notes||""})),t=URL.createObjectURL(new Blob([JSON.stringify(e,null,2)],{type:"application/json"})),a=document.createElement("a");a.href=t,a.download="ssh-connections.json",a.click(),setTimeout(()=>URL.revokeObjectURL(t),0),v(`Exported ${e.length} connection${1===e.length?"":"s"} without secrets`)}function Z(e){const t=e.replace(/-/g,"+").replace(/_/g,"/"),a=atob(t.padEnd(4*Math.ceil(t.length/4),"="));return Uint8Array.from(a,e=>e.charCodeAt(0))}function ee(e){const t=new Uint8Array(e);let a="";return t.forEach(e=>{a+=String.fromCharCode(e)}),btoa(a).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function te(e){return e.challenge=Z(e.challenge),e.user?.id&&(e.user.id=Z(e.user.id)),e.excludeCredentials&&e.excludeCredentials.forEach(e=>{e.id=Z(e.id)}),e.allowCredentials&&e.allowCredentials.forEach(e=>{e.id=Z(e.id)}),e}function ae(e){const t={};for(const a of["clientDataJSON","attestationObject","authenticatorData","signature","userHandle"])e.response[a]&&(t[a]=ee(e.response[a]));return e.response.getTransports&&(t.transports=e.response.getTransports()),{id:e.id,rawId:ee(e.rawId),type:e.type,response:t}}async function se(){const e=te(await f("/api/auth/passkey/register/options",{method:"POST"})),t=await navigator.credentials.create({publicKey:e});await f("/api/auth/passkey/register/verify",{method:"POST",body:ae(t)}),v("Passkey enrolled"),await q()}function ne(e){return new Promise((t,a)=>{l=t,d=a,h("#masterDialogTitle").textContent=e?"Set fallback master password":"Authenticate to reveal",h("#masterConfirmField").hidden=!e,h("#masterPasswordConfirm").required=e,h("#masterPassword").autocomplete=e?"new-password":"current-password",h("#masterPassword").value="",h("#masterPasswordConfirm").value="",h("#masterDialog").dataset.enrolling=e?"true":"false",h("#masterDialog").showModal(),h("#masterPassword").focus()})}function re(){h("#masterDialog").open&&h("#masterDialog").close(),d&&d(new Error("Authentication cancelled")),l=null,d=null,h("#masterPassword").value="",h("#masterPasswordConfirm").value=""}function ie(){clearInterval(i),h("#revealedSecret").textContent="",h("#revealDialog").open&&h("#revealDialog").close()}async function oe(e){try{await e()}catch(e){v(e.message||String(e),!0)}}document.addEventListener("DOMContentLoaded",()=>{history.replaceState({},"","/"),b(n.theme),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{"system"===n.theme&&b("system")}),oe(q),setInterval(N,3e4),h(".primary-nav").addEventListener("click",e=>{const t=e.target.closest(".nav-item");if(!t)return;const a=t.dataset.view;P(a),"contexts"!==a&&"servers"!==a||oe(async()=>{(await C())["contexts"===a?"openContextManager":"enhanceConnections"]($())})}),h("#refreshButton").addEventListener("click",()=>oe(q)),h("#addServerButton").addEventListener("click",()=>K()),h("#workspaceAddHeaderButton").addEventListener("click",()=>K()),h("#sidebarAddServerButton").addEventListener("click",()=>K()),h("#emptyAddServerButton").addEventListener("click",()=>K()),h("#addCredentialButton").addEventListener("click",()=>z()),h("#emptyChooseHostButton").addEventListener("click",()=>oe(async()=>{!x().length&&n.servers.length&&L("*");const e=document.querySelector(".recent-host-row");e&&await A(e.dataset.id)})),h("#emptyImportButton").addEventListener("click",()=>oe(J)),h("#viewAllHostsButton").addEventListener("click",()=>{P("servers"),oe(async()=>(await C()).enhanceConnections($()))}),h("#recentHostRows").addEventListener("click",e=>{const t=e.target.closest(".recent-host-row");t&&oe(()=>A(t.dataset.id))}),h("#themeSelect").addEventListener("change",()=>b(h("#themeSelect").value,!0)),h("#hostContextSelect").addEventListener("change",()=>L(h("#hostContextSelect").value)),h("#hostSearchInput").addEventListener("input",()=>{n.hostQuery=h("#hostSearchInput").value,cancelAnimationFrame(p),p=requestAnimationFrame(()=>{p=0,T()})}),h("#workspaceHostList").addEventListener("click",e=>{const t=e.target.closest("[data-sidebar-filter-context]");if(t)return L(t.dataset.sidebarFilterContext);const a=e.target.closest("[data-sidebar-contexts]");if(a)return oe(async()=>{(await C()).openHostContextPicker($(),a.dataset.id)});const s=e.target.closest(".workspace-host");s&&A(s.dataset.id)}),h("#sidebarContextList").addEventListener("click",e=>{const t=e.target.closest("[data-sidebar-filter-context]");t&&L(t.dataset.sidebarFilterContext===n.hostContext?"*":t.dataset.sidebarFilterContext)}),h("#sidebarAddContextButton").addEventListener("click",()=>{h("#sidebarContextForm").hidden=!h("#sidebarContextForm").hidden,h("#sidebarContextForm").hidden||h("#sidebarContextName").focus()}),h("#sidebarContextForm").addEventListener("submit",e=>{e.preventDefault(),oe(X)}),h("#sidebarEditContextsButton").addEventListener("click",()=>oe(async()=>(await C()).openContextManager($()))),h("#createContextButton").addEventListener("click",()=>oe(async()=>{const e=await C();e.openContextManager($()),e.focusContextCreation()})),h("#fileServerSelect").addEventListener("change",()=>A(h("#fileServerSelect").value)),h("#fileHomeButton").addEventListener("click",()=>D("~")),h("#fileUpButton").addEventListener("click",()=>{n.files.parent&&D(n.files.parent)}),h("#fileRefreshButton").addEventListener("click",()=>D(n.files.path||"~")),h("#fileRetryButton").addEventListener("click",()=>D(n.files.requestedPath||"~")),h("#fileShowHidden").addEventListener("change",()=>{n.files.showHidden=h("#fileShowHidden").checked,n.files.visibleLimit=250,I()}),h("#fileSearchInput").addEventListener("input",()=>{n.files.query=h("#fileSearchInput").value,n.files.visibleLimit=250,cancelAnimationFrame(u),u=requestAnimationFrame(()=>{u=0,I()})}),h("#filePathForm").addEventListener("submit",e=>{e.preventDefault(),D(h("#filePathInput").value||"~")}),h("#fileBreadcrumbs").addEventListener("click",e=>{const t=e.target.closest(".breadcrumb-button");t&&D(t.dataset.path)}),h("#fileRows").addEventListener("click",e=>{const t=e.target.closest(".open-directory"),a=e.target.closest(".copy-file-reference");t&&D(t.dataset.path),a&&oe(()=>j(a.dataset.path))}),h("#fileTableWrap").addEventListener("click",e=>{const t=e.target.closest(".sort-button");t&&(n.files.sortBy===t.dataset.sort?n.files.sortDirection="asc"===n.files.sortDirection?"desc":"asc":(n.files.sortBy=t.dataset.sort,n.files.sortDirection="asc"),n.files.visibleLimit=250,I())}),h("#fileLoadMoreButton").addEventListener("click",()=>{n.files.visibleLimit+=250,I()}),h("#copyDirectoryReferenceButton").addEventListener("click",()=>oe(()=>j(n.files.path))),h("#copyVisibleReferencesButton").addEventListener("click",()=>oe(F)),h("#workspaceTestButton").addEventListener("click",()=>oe(async()=>{const e=n.files.serverId;if(e){h("#workspaceTestButton").disabled=!0;try{const t=await f(`/api/servers/${e}/test`,{method:"POST"});v(t.ok?`Connected in ${t.latency_ms} ms`:t.message,!t.ok),await q()}finally{h("#workspaceTestButton").disabled=!1}}})),h("#workspaceCopyCommandButton").addEventListener("click",()=>oe(async()=>{const e=n.servers.find(e=>e.id===n.files.serverId);e&&(await navigator.clipboard.writeText(`serverctl connect ${e.alias}`),v("SSH command copied"))})),h("#workspaceEditButton").addEventListener("click",()=>{const e=n.servers.find(e=>e.id===n.files.serverId);e&&K(e)}),h("#newServerCredentialButton").addEventListener("click",G),h("#cancelServerCredentialButton").addEventListener("click",V),h("#serverCredentialNewKind").addEventListener("change",U),h("#saveServerCredentialButton").addEventListener("click",()=>oe(Q)),h("#credentialKind").addEventListener("change",()=>{W(),h("#credentialSecret").required=!h("#credentialId").value&&"password"===h("#credentialKind").value}),h("#credentialSearchInput").addEventListener("input",()=>{n.credentialQuery=h("#credentialSearchInput").value,R()}),h("#credentialKindFilter").addEventListener("change",()=>{n.credentialKind=h("#credentialKindFilter").value,R()}),document.querySelectorAll(".close-dialog").forEach(e=>e.addEventListener("click",()=>e.closest("dialog").close())),document.querySelectorAll(".close-reveal").forEach(e=>e.addEventListener("click",ie)),document.querySelectorAll(".cancel-master").forEach(e=>e.addEventListener("click",re)),h("#masterDialog").addEventListener("cancel",e=>{e.preventDefault(),re()}),h("#masterForm").addEventListener("submit",e=>{e.preventDefault();const t=h("#masterPassword").value;if("true"===h("#masterDialog").dataset.enrolling&&t!==h("#masterPasswordConfirm").value)return void v("Master passwords do not match",!0);h("#masterDialog").close(),h("#masterPassword").value="",h("#masterPasswordConfirm").value="";const a=l;l=null,d=null,a(t)}),h("#serverForm").addEventListener("submit",e=>{e.preventDefault(),oe(async()=>{const e=h("#serverId").value,t={alias:h("#serverAlias").value,hostname:h("#serverHostname").value,port:Number(h("#serverPort").value),username:h("#serverUsername").value,credential_id:h("#serverCredential").value||null,proxy_jumps:h("#serverProxy").value.split(",").map(e=>e.trim()).filter(Boolean),tags:h("#serverTags").value.split(",").map(e=>e.trim()).filter(Boolean),notes:h("#serverNotes").value};await f(e?`/api/servers/${e}`:"/api/servers",{method:e?"PUT":"POST",body:t}),h("#serverDialog").close(),v(e?"Server updated":"Server added"),await q()})}),h("#credentialForm").addEventListener("submit",e=>{e.preventDefault(),oe(async()=>{const e=h("#credentialId").value,t=h("#credentialKind").value,a={label:h("#credentialLabel").value,kind:t};"password"===t&&h("#credentialSecret").value&&(a.secret=h("#credentialSecret").value),"key"===t&&(a.key_path=h("#credentialKeyPath").value,h("#credentialPassphrase").value&&(a.passphrase=h("#credentialPassphrase").value),e&&h("#credentialClearPassphrase").checked&&(a.clear_passphrase=!0)),await f(e?`/api/credentials/${e}`:"/api/credentials",{method:e?"PUT":"POST",body:a}),h("#credentialSecret").value="",h("#credentialPassphrase").value="",h("#credentialDialog").close(),v(e?"Credential updated":"Credential added"),await q()})}),h("#serverRows").addEventListener("click",e=>oe(async()=>{const t=e.target.closest("button");if(t)if(t.classList.contains("browse-files"))await A(t.dataset.id);else if(t.classList.contains("test-server")){t.disabled=!0;const e=await f(`/api/servers/${t.dataset.id}/test`,{method:"POST"});v(e.ok?`Connected in ${e.latency_ms} ms`:e.message,!e.ok),await q()}else if(t.classList.contains("copy-command"))await navigator.clipboard.writeText(`serverctl connect ${t.dataset.alias}`),v("Connection command copied");else if(t.classList.contains("edit-server"))K(n.servers.find(e=>e.id===t.dataset.id));else if(t.classList.contains("delete-server")){const e=n.servers.find(e=>e.id===t.dataset.id);confirm(`Delete server ${e.alias}?`)&&(await f(`/api/servers/${t.dataset.id}`,{method:"DELETE"}),v("Server deleted"),await q())}})),h("#credentialRows").addEventListener("click",e=>oe(async()=>{const t=e.target.closest("button");if(t)if(t.classList.contains("copy-key-path"))await navigator.clipboard.writeText(t.dataset.path),v("Key path copied");else if(t.classList.contains("reveal-credential"))await async function(e){if(n.auth.passkeys>0&&window.PublicKeyCredential)try{const t=te(await f("/api/auth/reveal/options",{method:"POST",body:{credential_id:e}})),a=await navigator.credentials.get({publicKey:t});return void await f("/api/auth/reveal/verify",{method:"POST",body:{credential_id:e,response:ae(a)}})}catch(e){if(!n.auth.master_password_enrolled)throw e;v("Passkey was unavailable; use the fallback master password",!0)}if(!n.auth.master_password_enrolled)throw new Error("Enroll a passkey or fallback master password first");const t=await ne(!1);await f("/api/auth/master/verify",{method:"POST",body:{credential_id:e,password:t}})}(t.dataset.id),function(e){clearInterval(i);let t=15;h("#revealedSecret").textContent=e,h("#revealCountdown").textContent=t,h("#revealDialog").showModal(),i=setInterval(()=>{t-=1,h("#revealCountdown").textContent=t,t<=0&&ie()},1e3)}((await f(`/api/credentials/${t.dataset.id}/reveal`,{method:"POST"})).value);else if(t.classList.contains("edit-credential"))z(n.credentials.find(e=>e.id===t.dataset.id));else if(t.classList.contains("delete-credential")){const e=n.credentials.find(e=>e.id===t.dataset.id);confirm(`Delete credential ${e.label}? Stored secrets will also be removed.`)&&(await f(`/api/credentials/${t.dataset.id}`,{method:"DELETE"}),v("Credential deleted"),await q())}})),h("#importButton").addEventListener("click",()=>oe(J)),h("#exportConnectionsButton").addEventListener("click",Y),h("#enrollPasskeyButton").addEventListener("click",()=>oe(se)),h("#enrollMasterButton").addEventListener("click",()=>oe(async()=>{const e=await ne(!0);await f("/api/auth/master/enroll",{method:"POST",body:{password:e}}),v("Fallback master password enrolled"),await q()})),h("#copySecretButton").addEventListener("click",()=>oe(async()=>{await navigator.clipboard.writeText(h("#revealedSecret").textContent),v("Secret copied to clipboard")})),document.addEventListener("keydown",e=>{const t=e.target,a=t instanceof HTMLInputElement||t instanceof HTMLTextAreaElement||t instanceof HTMLSelectElement;"/"===e.key&&"workspace"===n.view&&n.files.serverId&&!a&&(e.preventDefault(),h("#fileSearchInput").focus())})});
@@ -0,0 +1 @@
1
+ .cx-toolbar{border:1px solid var(--line);background:color-mix(in srgb,var(--surface) 95%,transparent);border-radius:10px;overflow:hidden}.cx-toolbar-main{align-items:center;gap:12px;min-height:54px;padding:9px 14px;display:flex}.cx-host-search{flex:1;min-width:220px;margin:0;position:relative}.cx-host-search:before{content:"";border:1.5px solid var(--faint);border-radius:50%;width:10px;height:10px;position:absolute;top:9px;left:11px}.cx-host-search:after{content:"";background:var(--faint);width:6px;height:1.5px;position:absolute;top:20px;left:21px;transform:rotate(45deg)}.cx-host-search input,.cx-library input{border:1px solid var(--line-strong);background:var(--surface-soft);width:100%;height:35px;color:var(--ink);border-radius:7px;padding:7px 10px;font-size:10px}.cx-host-search input{padding-left:34px}.cx-host-search input:focus,.cx-library input:focus,.cx-picker-search input:focus{border-color:var(--accent);outline:3px solid var(--focus-ring)}.cx-shown{color:var(--muted);white-space:nowrap;font-size:9px}.cx-batch{border-top:1px solid var(--line);background:var(--accent-soft);align-items:center;gap:8px;min-height:50px;padding:8px 13px;display:flex}.cx-batch-label{min-width:80px;color:var(--ink);font-size:10px;font-weight:750}.cx-batch-hint,.cx-context-host-count span,.cx-option-copy small,.cx-picker-head span,.cx-search-empty{color:var(--muted);font-size:8px}.cx-batch button,.cx-library button{background:var(--accent);min-height:33px;color:var(--on-accent);border:1px solid #0000;border-radius:7px;padding:6px 10px;font-size:9px;font-weight:740}.cx-batch button:hover,.cx-library button:hover{background:var(--accent-hover)}.cx-batch button.secondary,.cx-library button.secondary{border-color:var(--line-strong);color:var(--ink);background:0 0}.cx-batch button.secondary:hover,.cx-library button.secondary:hover{background:var(--control)}.cx-batch .cx-context-trigger{justify-content:space-between;align-items:center;gap:18px;min-width:145px;display:inline-flex}.cx-batch .cx-clear{color:var(--muted);background:0 0;border-color:#0000;margin-left:auto}.cx-batch .cx-clear:hover{background:var(--control);color:var(--ink)}.cx-toolbar[aria-busy=true]{opacity:.65}.cx-context-picker[aria-busy=true] button,.cx-toolbar[aria-busy=true] button{pointer-events:none}.cx-search-empty{text-align:center;margin:0;padding:38px 20px;font-size:10px}.cx-select-cell,.cx-select-column{min-width:44px;text-align:center!important;width:44px!important;padding-right:4px!important}.cx-select-cell input,.cx-select-column input{width:15px;height:15px;accent-color:var(--accent);margin:0}.cx-picker-create:hover,.cx-picker-option:hover,.management-table tr.cx-selected,.management-table tr.cx-selected:hover{background:var(--accent-soft)}.cx-context-picker,.cx-picker-search input{border:1px solid var(--line-strong);color:var(--ink)}.cx-context-picker{z-index:80;background:var(--surface);border-radius:10px;position:fixed;overflow:hidden}.cx-picker-head{gap:3px;padding:13px 14px 9px;display:grid}.cx-picker-head strong{font-size:11px}.cx-picker-search{margin:0;padding:0 8px 8px;display:block}.cx-picker-search input{background:var(--surface-soft);border-radius:7px;width:100%;height:35px;padding:7px 9px;font-size:10px}.cx-picker-list{border-top:1px solid var(--line);max-height:min(300px,48vh);padding:4px 5px 5px;overflow-y:auto}.cx-picker-create,.cx-picker-option{width:100%;min-height:45px;color:var(--ink);text-align:left;background:0 0;border:0;border-radius:7px;align-items:center;gap:10px;padding:6px 8px;display:flex}.cx-create-icon,.cx-option-check{border:1px solid var(--line-strong);color:#0000;border-radius:5px;flex:0 0 20px;place-items:center;width:20px;height:20px;font-size:11px;font-weight:850;display:grid}.cx-create-icon{color:var(--accent);border-style:dashed;font-size:15px}.cx-picker-option.all .cx-option-check{border-color:var(--accent);background:var(--accent);color:var(--on-accent)}.cx-picker-option.some .cx-option-check{border-color:var(--accent-border);background:var(--accent-soft);color:var(--accent)}.cx-option-copy{gap:3px;min-width:0;display:grid}.cx-option-copy strong{text-overflow:ellipsis;white-space:nowrap;font-size:10px;overflow:hidden}.cx-picker-empty{color:var(--muted);text-align:center;margin:0;padding:24px 12px;font-size:9px}.cx-picker-manage{border:0;border-top:1px solid var(--line);background:var(--surface-soft);width:100%;min-height:39px;color:var(--muted);text-align:left;padding:7px 12px;font-size:9px;font-weight:720}.cx-picker-manage:hover{background:var(--control);color:var(--ink)}.cx-context-picker[aria-busy=true]{opacity:.65}.context-view{max-width:1480px}.cx-library{gap:18px;display:grid}.cx-library-surface{border:1px solid var(--line);background:color-mix(in srgb,var(--surface) 95%,transparent);border-radius:11px;overflow:hidden}.cx-library-toolbar{grid-template-columns:minmax(300px,1fr) minmax(300px,.85fr);align-items:center;gap:clamp(30px,8vw,120px);min-height:108px;padding:16px 18px;display:grid}.cx-library .cx-assignment-guide{border:1px solid var(--line);background:var(--surface-soft);width:min(520px,100%);min-height:72px;color:var(--ink);text-align:left;border-radius:9px;grid-template-columns:44px minmax(0,1fr) auto;align-items:center;gap:12px;padding:11px 14px;display:grid}.cx-library .cx-assignment-guide:hover{border-color:var(--accent-border);background:var(--row-hover);color:var(--ink)}.cx-guide-icon{background:var(--accent-soft);width:42px;height:42px;color:var(--accent);border-radius:9px;place-items:center;font-size:18px;display:grid}.cx-assignment-guide>span:nth-child(2){gap:5px;min-width:0;display:grid}.cx-assignment-guide strong{font-size:11px}.cx-assignment-guide small{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;font-size:9px;overflow:hidden}.cx-assignment-guide>b{color:var(--muted);font-size:20px;font-weight:400}.cx-library-tools{justify-content:flex-end;align-items:center;gap:8px;min-width:0;display:flex}.cx-library-search{width:min(460px,100%);position:relative}.cx-library-search:before{content:"";z-index:1;border:1.5px solid var(--faint);border-radius:50%;width:10px;height:10px;position:absolute;top:11px;left:12px}.cx-library-search input{height:40px;padding-left:35px}.cx-create-context{border-top:1px solid var(--line);background:var(--accent-soft);grid-template-columns:minmax(180px,280px) auto auto;gap:7px;padding:10px 18px;display:grid}.cx-context-table{width:100%}.cx-context-header,.cx-context-row{grid-template-columns:minmax(150px,1.15fr) minmax(150px,1.25fr) 75px minmax(130px,1fr) minmax(110px,.8fr) 110px 56px;align-items:center;gap:13px;display:grid}.cx-context-header{border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:var(--surface-soft);min-height:43px;color:var(--faint);letter-spacing:.09em;text-transform:uppercase;padding:7px 18px;font-size:8px;font-weight:820}.cx-context-row{border-bottom:1px solid var(--line);min-height:74px;padding:11px 18px}.cx-context-row:last-child{border-bottom:0}.cx-context-row:hover{background:var(--row-hover)}.cx-context-row.selected{border:1px solid var(--accent-border);background:linear-gradient(100deg,var(--accent-soft),color-mix(in srgb,var(--accent-soft) 45%,transparent));border-radius:8px;margin:5px;padding-inline:13px}.cx-context-row.deleting{background:var(--danger-soft)}.cx-context-name{gap:4px;min-width:0;display:grid}.cx-library .cx-use-context{max-width:100%;min-height:25px;color:var(--ink);text-align:left;background:0 0;border:0;justify-self:start;padding:0;font-size:11px}.cx-library .cx-use-context:hover{color:var(--accent);background:0 0}.cx-context-state{color:var(--success);font-size:8px}.cx-context-description,.cx-context-updated{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;font-size:9px;overflow:hidden}.cx-context-host-count{gap:2px;display:grid}.cx-context-host-count strong{font-size:14px}.cx-context-usage{min-width:0;color:var(--muted);align-items:center;gap:7px;font-size:8px;display:flex}.cx-context-usage i{background:var(--faint);border-radius:50%;flex:0 0 5px;width:5px;height:5px}.cx-context-usage i.active,.cx-detail-alias>i.ok{background:var(--success)}.cx-context-label span,.cx-context-usage span,.cx-library .cx-use-context{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.cx-context-label span{border:1px solid var(--accent-border);max-width:100%;color:var(--accent);border-radius:5px;padding:4px 7px;font-size:8px;display:inline-flex}.cx-context-actions{justify-content:flex-end;gap:5px;display:flex}.cx-context-actions button{min-height:30px;padding:5px 8px}.cx-library button.danger{color:var(--danger);background:0 0;border-color:#0000}.cx-library .cx-confirm-delete:hover,.cx-library button.danger:hover{background:var(--danger-soft);color:var(--danger)}.cx-row-more{min-width:34px;padding-inline:5px!important}.cx-context-empty{min-height:190px;color:var(--muted);place-content:center;justify-items:center;gap:6px;display:grid}.cx-context-empty strong{color:var(--ink);font-size:12px}.cx-context-empty span{font-size:9px}.cx-library-footer{border-top:1px solid var(--line);min-height:46px;color:var(--muted);justify-content:flex-end;align-items:center;padding:9px 18px;font-size:9px;display:flex}.cx-library[aria-busy=true]{opacity:.68}.cx-library[aria-busy=true] button{pointer-events:none}.cx-context-detail{border:1px solid var(--line);background:color-mix(in srgb,var(--surface) 95%,transparent);border-radius:11px;padding:20px}.cx-detail-head{justify-content:space-between;align-items:center;gap:18px;display:flex}.cx-detail-title{align-items:center;gap:13px;min-width:0;display:flex}.cx-stack-icon{background:var(--accent-soft);width:42px;height:42px;color:var(--accent);border-radius:9px;flex:0 0 42px;place-items:center;font-size:22px;display:grid}.cx-detail-title h3{margin:0 0 5px;font-size:15px}.cx-detail-title h3 small{background:var(--control);color:var(--muted);vertical-align:2px;border-radius:999px;margin-left:7px;padding:3px 7px;font-size:8px}.cx-detail-title h3 small.active{background:var(--success-soft);color:var(--success)}.cx-detail-title p{color:var(--muted);margin:0;font-size:9px}.cx-detail-buttons{align-items:center;gap:7px;display:flex}.cx-detail-stats{grid-template-columns:170px 220px 1fr;margin:24px 0 18px;display:grid}.cx-detail-stats>span{border-right:1px solid var(--line);align-content:center;gap:4px;min-height:48px;padding:0 22px;display:grid}.cx-detail-stats>span:first-child{padding-left:2px}.cx-detail-stats>span:last-child{border-right:0}.cx-detail-stats strong{font-size:11px}.cx-detail-stats>span:first-child strong{font-size:18px}.cx-detail-stats small,.cx-detail-status b{color:var(--muted);font-size:8px}.cx-detail-hosts{border:1px solid var(--line);border-radius:9px;overflow:hidden}.cx-detail-hosts>header{align-items:center;min-height:50px;padding:12px 16px;display:flex}.cx-detail-hosts h4{margin:0;font-size:11px}.cx-detail-host,.cx-detail-host-head{grid-template-columns:minmax(140px,1.15fr) minmax(180px,1.25fr) minmax(140px,1fr) 110px 85px 145px;align-items:center;gap:12px;display:grid}.cx-detail-host-head{border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:var(--surface-soft);min-height:36px;color:var(--faint);letter-spacing:.08em;text-transform:uppercase;padding:7px 16px;font-size:8px;font-weight:800}.cx-detail-host{border-bottom:1px solid var(--line);min-height:70px;padding:10px 16px}.cx-detail-host:last-child{border-bottom:0}.cx-detail-host:hover{background:var(--row-hover)}.cx-detail-alias{grid-template-columns:6px minmax(0,1fr);align-items:center;gap:9px;min-width:0;display:grid}.cx-detail-alias>i{background:var(--faint);border-radius:50%;width:6px;height:6px}.cx-detail-alias>i.failed{background:var(--danger)}.cx-detail-alias>i.stale{background:var(--warning)}.cx-detail-alias span{gap:4px;min-width:0;display:grid}.cx-detail-alias strong{text-overflow:ellipsis;white-space:nowrap;font-size:10px;overflow:hidden}.cx-detail-alias small,.cx-detail-host code,.cx-detail-labels span{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;font-size:8px;overflow:hidden}.cx-detail-labels{gap:4px;display:flex;overflow:hidden}.cx-detail-labels span{background:var(--control);border-radius:5px;max-width:82px;padding:3px 6px;font-size:7px}.cx-detail-status{gap:3px;display:grid}.cx-detail-seen,.cx-detail-status small{color:var(--faint);font-size:8px}.cx-detail-status.ok b{color:var(--success)}.cx-detail-status.failed b{color:var(--danger)}.cx-detail-status.stale b{color:var(--warning)}.cx-detail-actions{justify-content:flex-end;gap:5px;display:flex}.cx-detail-actions button{min-width:35px;min-height:30px;padding:5px 8px}.cx-detail-empty{min-height:100px;color:var(--muted);place-items:center;font-size:9px;display:grid}.context-tag-picker{border:1px solid var(--line-strong);background:var(--surface);border-radius:8px;flex-wrap:wrap;align-items:center;gap:5px;min-height:42px;padding:5px 7px;display:flex;position:relative}.context-tag-picker:focus-within{border-color:var(--accent);outline:3px solid var(--focus-ring)}.ctp-chips{display:contents}.ctp-chip{border:1px solid var(--accent-border);background:var(--accent-soft);min-height:27px;color:var(--accent);border-radius:999px;align-items:center;gap:3px;padding:3px 3px 3px 8px;font-size:10px;font-weight:720;display:inline-flex}.context-tag-picker .ctp-remove{min-width:21px;min-height:21px;color:inherit;background:0 0;border:0;border-radius:999px;padding:0;font-size:15px}.context-tag-picker .ctp-remove:hover{background:var(--accent-border);color:var(--accent)}.context-tag-picker .ctp-editor{min-width:150px;min-height:28px;color:var(--ink);background:0 0;border:0;outline:0;flex:1;padding:3px;font-size:11px}.context-tag-picker .ctp-editor:focus{border:0;outline:0}.ctp-menu{z-index:20;border:1px solid var(--line-strong);background:var(--surface);border-radius:9px;max-height:240px;padding:5px;position:absolute;top:calc(100% + 5px);left:0;right:0;overflow-y:auto}.context-tag-picker .ctp-option{width:100%;min-height:38px;color:var(--ink);text-align:left;background:0 0;border:0;border-radius:7px;justify-content:space-between;align-items:center;gap:10px;padding:7px 9px;display:flex}.context-tag-picker .ctp-option.active,.context-tag-picker .ctp-option:hover{background:var(--accent-soft);color:var(--accent)}.ctp-option small{color:var(--muted);font-size:8px}@media (width<=1100px){.cx-context-header,.cx-context-row{grid-template-columns:minmax(140px,1fr) minmax(140px,1fr) 65px minmax(110px,.9fr) minmax(90px,.7fr) 90px 46px;gap:9px}.cx-detail-host,.cx-detail-host-head{grid-template-columns:minmax(120px,1fr) minmax(160px,1.15fr) minmax(110px,.8fr) 100px 70px 120px;gap:8px}}@media (width<=820px){.cx-library-toolbar{grid-template-columns:1fr;gap:12px}.cx-library .cx-assignment-guide,.cx-library-search{width:100%}.cx-context-table{overflow-x:auto}.cx-context-header,.cx-context-row{min-width:900px}.cx-detail-head{flex-direction:column;align-items:stretch}.cx-detail-buttons{flex-wrap:wrap}.cx-detail-stats{grid-template-columns:repeat(3,1fr)}.cx-detail-hosts{overflow-x:auto}.cx-detail-host,.cx-detail-host-head{min-width:900px}}@media (width<=640px){.cx-batch,.cx-toolbar-main{flex-wrap:wrap;align-items:stretch}.cx-host-search{min-width:75%}.cx-batch-hint{display:none}.cx-batch .cx-clear{margin-left:0}.cx-create-context{grid-template-columns:1fr auto}.cx-create-context .secondary{grid-column:1/-1}.cx-context-detail{padding:13px}.cx-detail-stats{grid-template-columns:1fr}.cx-detail-stats>span{border-right:0;border-bottom:1px solid var(--line);min-height:44px;padding:8px 2px}.cx-detail-stats>span:last-child{border-bottom:0}}
@@ -0,0 +1 @@
1
+ let e,t;const n=new WeakMap;function a(){if(document.querySelector("link[data-context-styles]"))return;const e=document.createElement("link");e.rel="stylesheet",e.href="/assets/contexts.css",e.dataset.contextStyles="true",document.head.append(e)}function s(e,t,n){const a=document.createElement(e);return t&&(a.className=t),null!=n&&(a.textContent=n),a}function o(e){return String(e??"").replace(/[&<>'"]/g,e=>({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[e]))}function c(e){return(e()||[]).map(e=>"string"==typeof e?e:e.name).filter(Boolean).sort((e,t)=>e.localeCompare(t,void 0,{sensitivity:"base"}))}function i(e){return e.length>0&&e.length<=40&&!/[,\x00-\x1f\x7f]/.test(e)}function r(e){const t=new Set;return e.value.split(",").map(e=>e.trim()).filter(e=>{const n=e.toLocaleLowerCase();return!(!e||t.has(n)||(t.add(n),0))})}function l(e){e.source.value=e.selected.join(", "),e.chips.replaceChildren(),e.selected.forEach(t=>{const n=s("span","ctp-chip");n.append(s("span","",t));const a=s("button","ctp-remove","×");a.type="button",a.dataset.removeTag=t,a.setAttribute("aria-label",`Remove ${t}`),n.append(a),e.chips.append(n)}),e.editor.placeholder=e.selected.length?"Add another…":"Type or choose a tag…"}function d(e){const t=function(e){const t=e.editor.value.trim(),n=new Set(e.selected.map(e=>e.toLocaleLowerCase())),a=c(e.getContexts).filter(e=>!n.has(e.toLocaleLowerCase())).filter(e=>!t||e.toLocaleLowerCase().includes(t.toLocaleLowerCase())).map(e=>({name:e,create:!1}));return i(t)&&!c(e.getContexts).some(e=>e.toLocaleLowerCase()===t.toLocaleLowerCase())&&a.unshift({name:t,create:!0}),a.slice(0,8)}(e);if(e.choices=t,!e.open||!t.length)return e.menu.hidden=!0,void e.editor.setAttribute("aria-expanded","false");e.highlighted=Math.min(Math.max(e.highlighted,0),t.length-1),e.menu.replaceChildren(),t.forEach((t,n)=>{const a=s("button","ctp-option"+(n===e.highlighted?" active":""));a.type="button",a.role="option",a.dataset.pickTag=t.name,a.setAttribute("aria-selected",String(n===e.highlighted)),a.append(s("span","",t.create?`Create “${t.name}”`:t.name)),a.append(s("small","",t.create?"New tag":"Existing tag")),e.menu.append(a)}),e.menu.hidden=!1,e.editor.setAttribute("aria-expanded","true")}function p(e,t){e.toast&&e.toast(t,!0)}function u(e,t){const n=t.trim();return i(n)?e.selected.some(e=>e.toLocaleLowerCase()===n.toLocaleLowerCase())?(e.editor.value="",!0):e.selected.length>=20?(p(e,"A host can use at most 20 tags"),!1):(e.selected.push(n),e.editor.value="",e.highlighted=0,l(e),d(e),!0):(n&&p(e,"Tags must be 1–40 printable characters without commas"),!1)}export function enhanceTagPicker({input:e,getContexts:t,toast:o}){a();const c=n.get(e);if(c)return c.getContexts=t,c.toast=o,c.selected=r(e),c.editor.value="",c.open=!1,l(c),d(c),c.wrapper;e.hidden=!0;const i=s("div","context-tag-picker"),p=s("span","ctp-chips"),h=s("input","ctp-editor");h.type="text",h.maxLength=40,h.autocomplete="off",h.setAttribute("role","combobox"),h.setAttribute("aria-label","Add a tag"),h.setAttribute("aria-autocomplete","list"),h.setAttribute("aria-expanded","false");const m=s("div","ctp-menu");m.role="listbox",m.hidden=!0,i.append(p,h,m),e.after(i);const g={source:e,wrapper:i,chips:p,editor:h,menu:m,getContexts:t,toast:o,selected:r(e),choices:[],highlighted:0,open:!1};return n.set(e,g),l(g),h.addEventListener("focus",()=>{g.open=!0,g.highlighted=0,d(g)}),h.addEventListener("input",()=>{const e=h.value.split(",");e.length>1&&(e.slice(0,-1).forEach(e=>u(g,e)),h.value=e.at(-1).trimStart()),g.open=!0,g.highlighted=0,d(g)}),h.addEventListener("keydown",e=>{if("ArrowDown"===e.key||"ArrowUp"===e.key){e.preventDefault(),g.open=!0;const t="ArrowDown"===e.key?1:-1,n=Math.max(g.choices.length,1);g.highlighted=(g.highlighted+t+n)%n,d(g)}else if("Enter"===e.key){e.preventDefault();const t=g.open?g.choices[g.highlighted]:null;u(g,t?.name||h.value)}else"Backspace"===e.key&&!h.value&&g.selected.length?(g.selected.pop(),l(g),d(g)):"Escape"===e.key&&(g.open=!1,d(g))}),i.addEventListener("mousedown",e=>{e.target.closest(".ctp-option")&&e.preventDefault()}),i.addEventListener("click",e=>{const t=e.target.closest("[data-remove-tag]"),n=e.target.closest("[data-pick-tag]");t?(g.selected=g.selected.filter(e=>e.toLocaleLowerCase()!==t.dataset.removeTag.toLocaleLowerCase()),l(g),h.focus(),d(g)):n?(u(g,n.dataset.pickTag),h.focus()):e.target!==i&&e.target!==p||h.focus()}),i.addEventListener("focusout",()=>setTimeout(()=>{i.contains(document.activeElement)||(g.open=!1,d(g))},0)),i}function h(e,t,n){const a=e.contextKey(n);return(t.tags||[]).some(t=>e.contextKey(t)===a)}let m;function g(){return m?.open?"bulk"===m.kind?k("edit"):"detail"===m.kind?[...document.querySelectorAll("[data-detail-contexts]")].find(e=>e.dataset.id===m.hostId)||null:"sidebar"===m.kind?[...document.querySelectorAll("[data-sidebar-contexts]")].find(e=>e.dataset.id===m.hostId)||null:[...document.querySelectorAll("#serverRows tr[data-server-id]")].find(e=>e.dataset.serverId===m.hostId)?.querySelector("[data-edit-contexts]")||null:null}function y(e=!1){if(!m?.open)return;const t=g();m.open=!1,m.root.hidden=!0,t?.setAttribute("aria-expanded","false"),e&&t?.focus()}function x(){if(!m?.open)return;const e=g();if(!e||e.hidden)return y();const t=e.getBoundingClientRect(),n=m.root,a=Math.min(340,window.innerWidth-16);n.style.width=`${a}px`;const s=Math.min(n.offsetHeight,window.innerHeight-16),o=Math.max(8,Math.min(t.left,window.innerWidth-a-8)),c=t.bottom+6,i=c+s<=window.innerHeight-8?c:Math.max(8,t.top-s-6);n.style.left=`${o}px`,n.style.top=`${i}px`}function f(){const e=new Set(m.options.getState().servers.map(e=>e.id));return[...m.hostIds].forEach(t=>{e.has(t)||m.hostIds.delete(t)}),m.options.getState().servers.filter(e=>m.hostIds.has(e.id))}function b(){if(!m?.open)return;const{options:e,root:t,input:n,list:a}=m,o=f();if(!o.length)return y();const c=n.value.trim(),r=c.toLocaleLowerCase(),l=e.getState().contexts||[],d=l.filter(e=>!r||e.name.toLocaleLowerCase().includes(r));t.querySelector("[data-cap-title]").textContent=`Tags for ${o.length} host${1===o.length?"":"s"}`,a.replaceChildren(),d.forEach(t=>{const n=o.filter(n=>h(e,n,t.name)).length,c=n===o.length?"all":n?"some":"none",i=s("button",`cx-picker-option ${c}`);i.type="button",i.dataset.contextName=t.name,i.setAttribute("role","checkbox"),i.setAttribute("aria-checked","all"===c?"true":"some"===c?"mixed":"false");const r=s("span","cx-option-check","all"===c?"✓":"some"===c?"−":"");r.setAttribute("aria-hidden","true");const l=s("span","cx-option-copy");l.append(s("strong","",t.name),s("small","",1===o.length?"all"===c?"Assigned":"Not assigned":"all"===c?"All selected hosts":"some"===c?`${n} of ${o.length} hosts`:"Not assigned")),i.append(r,l),a.append(i)});const p=l.some(e=>e.name.toLocaleLowerCase()===r);if(i(c)&&!p){const e=s("button","cx-picker-create");e.type="button",e.dataset.createContext=c;const t=s("span","cx-create-icon","+");t.setAttribute("aria-hidden","true");const n=s("span","cx-option-copy");n.append(s("strong","",`Create “${c}”`),s("small","",`Create and assign to ${o.length} host${1===o.length?"":"s"}`)),e.append(t,n),a.prepend(e)}a.children.length||a.append(s("p","cx-picker-empty",c?"No matching tags":"No tags yet. Type to create one.")),x()}async function v(e){if(!m.busy){m.busy=!0,m.root.setAttribute("aria-busy","true");try{await e()}catch(e){m.options.toast(e.message||String(e),!0)}finally{m.busy=!1,m.root.removeAttribute("aria-busy"),b()}}}async function w(e){const{options:t,hostIds:n}=m,a=f(),s=new Set(t.getState().servers.filter(n=>h(t,n,e)).map(e=>e.id)),o=a.every(e=>s.has(e.id));n.forEach(e=>o?s.delete(e):s.add(e)),await t.api("/api/contexts",{method:"PUT",body:{name:e,server_ids:[...s]}}),await t.refresh(),t.toast(`${o?"Removed":"Added"} “${e}” ${o?"from":"to"} ${a.length} host${1===a.length?"":"s"}`)}async function S(e){const{options:t,hostIds:n,input:a}=m,s=await t.api("/api/contexts",{method:"POST",body:{name:e,server_ids:[...n]}});a.value="",await t.refresh(),t.toast(`Created “${s.name}” and assigned ${s.count} host${1===s.count?"":"s"}`)}function $(e,t,n,a=""){if(function(){if(m)return;const e=s("div","cx-context-picker");e.hidden=!0,e.setAttribute("role","dialog"),e.setAttribute("aria-label","Edit host tags"),e.innerHTML='\n <div class="cx-picker-head"><strong data-cap-title></strong><span>Click to add or remove</span></div>\n <label class="cx-picker-search"><span class="sr-only">Search or create tag</span><input type="search" maxlength="40" placeholder="Search or create tag…" autocomplete="off"></label>\n <div class="cx-picker-list"></div>\n <button type="button" class="cx-picker-manage">Manage tags…</button>',document.body.append(e),m={root:e,input:e.querySelector("input"),list:e.querySelector(".cx-picker-list"),hostIds:new Set,hostId:"",kind:"",options:null,open:!1,busy:!1},m.input.addEventListener("input",b),m.input.addEventListener("keydown",e=>{if("Escape"===e.key)e.preventDefault(),y(!0);else if("Enter"===e.key){const t=m.list.querySelector("[data-create-context]"),n=m.list.querySelector("[data-context-name]");(t||n)&&(e.preventDefault(),v(t?()=>S(t.dataset.createContext):()=>w(n.dataset.contextName)))}}),e.addEventListener("keydown",e=>{"Escape"===e.key&&e.target!==m.input&&(e.preventDefault(),y(!0))}),e.addEventListener("click",e=>{const t=e.target.closest("[data-context-name]"),n=e.target.closest("[data-create-context]");if(t)v(()=>w(t.dataset.contextName));else if(n)v(()=>S(n.dataset.createContext));else if(e.target.closest(".cx-picker-manage")){const e=m.options;y(),openContextManager(e)}}),document.addEventListener("pointerdown",t=>{!m.open||e.contains(t.target)||g()?.contains(t.target)||y()},!0),window.addEventListener("resize",x),window.addEventListener("scroll",x,!0)}(),m.open&&m.kind===n&&("bulk"===n||m.hostId===a))return y();y(),m.options=e,m.hostIds=new Set(t),m.hostId=a,m.kind=n,m.input.value="",m.open=!0,m.root.hidden=!1,g()?.setAttribute("aria-expanded","true"),b(),requestAnimationFrame(()=>m.input.focus())}function k(e){return document.querySelector(`[data-cx="${e}"]`)}function C(){const e=t.query.trim().toLocaleLowerCase();return t.options.getVisibleServers().filter(t=>!e||[t.alias,t.hostname,t.username,t.notes,...t.tags||[]].some(t=>String(t||"").toLocaleLowerCase().includes(e)))}function L(){if(!t)return;const{options:e,selected:n}=t,a=new Set(e.getState().servers.map(e=>e.id));[...n].forEach(e=>{a.has(e)||n.delete(e)});const o=C(),c=new Set(o.map(e=>e.id));document.querySelectorAll("#serverRows tr[data-server-id]").forEach(e=>{const t=e.dataset.serverId;let a=e.querySelector(".cx-select-cell");if(!a){a=s("td","cx-select-cell");const n=document.createElement("input");n.type="checkbox",n.dataset.cxHost=t,n.setAttribute("aria-label",`Select ${e.querySelector(".alias")?.textContent||"host"}`),a.append(n),e.prepend(a)}e.hidden=!c.has(t),e.classList.toggle("cx-selected",n.has(t)),a.querySelector("input").checked=n.has(t)});const i=k("select-all"),r=o.filter(e=>n.has(e.id)).length;i&&(i.disabled=0===o.length,i.checked=o.length>0&&r===o.length,i.indeterminate=r>0&&r<o.length),k("selected").textContent=n.size?`${n.size} selected`:"Select hosts",k("shown").textContent=`${o.length} shown`,k("batch").hidden=0===n.size,t.empty.hidden=!t.query||o.length>0,m?.open&&"bulk"===m.kind&&(m.hostIds=new Set(n),n.size?b():y())}export function enhanceConnections(e){a(),t||function(e){const n=document.querySelector("#connectionToolbarMount"),a=document.querySelector("#serversView .management-table thead tr");if(!a.querySelector(".cx-select-column")){const e=s("th","cx-select-column"),t=document.createElement("input");t.type="checkbox",t.dataset.cx="select-all",t.setAttribute("aria-label","Select all visible hosts"),e.append(t),a.prepend(e)}const o=s("div","cx-toolbar");o.innerHTML='\n <div class="cx-toolbar-main">\n <label class="cx-host-search"><span class="sr-only">Search connections</span><input data-cx="search" type="search" placeholder="Search alias, endpoint, or tag…"></label>\n <span class="cx-shown" data-cx="shown"></span>\n </div>\n <div class="cx-batch" data-cx="batch" hidden>\n <span class="cx-batch-label" data-cx="selected">Selected hosts</span>\n <button type="button" class="cx-context-trigger" data-cx="edit" data-action="edit-contexts" aria-haspopup="dialog" aria-expanded="false">Edit tags <span aria-hidden="true">⌄</span></button>\n <button type="button" class="secondary" data-action="test-selected">Test selected</button>\n <span class="cx-batch-hint">Choose existing or create new</span>\n <button type="button" class="cx-clear" data-action="clear-selection">Clear selection</button>\n </div>',n.append(o);const c=s("p","cx-search-empty","No connections match this search.");c.hidden=!0,document.querySelector("#serversView .management-surface .table-wrap").after(c),t={options:e,toolbar:o,empty:c,selected:new Set,query:""},k("search").addEventListener("input",e=>{t.query=e.target.value,L()}),k("select-all").addEventListener("change",e=>{C().forEach(n=>e.target.checked?t.selected.add(n.id):t.selected.delete(n.id)),L()}),o.addEventListener("click",n=>{const a=n.target.closest("[data-action]")?.dataset.action;"edit-contexts"===a?$(t.options,t.selected,"bulk"):"test-selected"===a?async function(){const{options:e,selected:n,toolbar:a}=t,s=[...n];if(s.length){a.setAttribute("aria-busy","true");try{const t=[];let n=0;await Promise.all(Array.from({length:Math.min(4,s.length)},async function(){for(;n<s.length;){const a=s[n++];try{t.push(await e.api(`/api/servers/${a}/test`,{method:"POST"}))}catch(e){t.push(null)}}}));const a=t.filter(e=>e?.ok).length;await e.refresh(),e.toast(`${a} of ${s.length} selected host${1===s.length?"":"s"} reachable`,a!==s.length)}finally{a.removeAttribute("aria-busy")}}}().catch(t=>e.toast(t.message||String(t),!0)):"clear-selection"===a&&(t.selected.clear(),L())}),document.querySelector("#serverRows").addEventListener("change",e=>{const n=e.target.closest("[data-cx-host]");n&&(n.checked?t.selected.add(n.dataset.cxHost):t.selected.delete(n.dataset.cxHost),L())}),document.querySelector("#serverRows").addEventListener("click",n=>{const a=n.target.closest("[data-edit-contexts]"),s=n.target.closest("[data-context]");if(a){const e=a.closest("tr[data-server-id]")?.dataset.serverId;e&&$(t.options,[e],"row",e)}else s&&(e.setHostContext(e.contextKey(s.dataset.context)),e.showView("servers"),L())})}(e),t.options=e,L()}export function openHostContextPicker(e,t){a(),$(e,[t],"sidebar",t)}function E(t){return e.container.querySelector(`[data-cm="${t}"]`)}function A(){return e.options.getState().contexts||[]}function K(t){return A().find(n=>e.options.contextKey(n.name)===t)}function q(t){return e.options.getState().servers.filter(n=>h(e.options,n,t.name))}function T(e){const t=e.map(e=>Date.parse(e.updated_at||"")).filter(Number.isFinite);return t.length?new Date(Math.max(...t)).toLocaleDateString(void 0,{month:"short",day:"numeric",year:"numeric"}):"—"}function M(){if(!e)return;const t=e.query.trim().toLocaleLowerCase(),n=A(),a=n.filter(e=>!t||e.name.toLocaleLowerCase().includes(t));new Set(n.map(t=>e.options.contextKey(t.name))).has(e.selectedKey)||(e.selectedKey=n[0]?e.options.contextKey(n[0].name):"");const c=E("rows");c.replaceChildren(),a.forEach(t=>{const n=e.options.contextKey(t.name),a=q(t),o=s("div",`cx-context-row${e.deleteKey===n?" deleting":""}${e.selectedKey===n?" selected":""}`);o.role="row";const i=s("div","cx-context-name");if(i.role="cell",e.editKey===n){const e=document.createElement("input");e.value=t.name,e.maxLength=40,e.dataset.cmRename=n,e.setAttribute("aria-label",`Rename ${t.name}`),i.append(e)}else{const e=s("button","cx-use-context",t.name);e.type="button",e.dataset.action="select",e.dataset.contextKey=n,e.title=`Open ${t.name}`,i.append(e),i.append(s("small","cx-context-state",a.length?"Active":"No hosts assigned"))}const r=s("div","cx-context-description","Reusable host tag");r.role="cell";const l=s("div","cx-context-host-count");l.role="cell",l.append(s("strong","",String(a.length)),s("span","","host"+(1===a.length?"":"s")));const d=s("div","cx-context-usage");d.role="cell",d.append(s("i",a.length?"active":""),s("span","",a.length?`Used by ${a.length} host${1===a.length?"":"s"}`:"Not assigned"));const p=s("div","cx-context-label");p.role="cell",p.append(s("span","",t.name));const u=s("div","cx-context-updated",T(a));u.role="cell";const h=s("div","cx-context-actions");if(h.role="cell",e.editKey===n){const e=s("button","","Save");e.dataset.action="save-rename",e.dataset.contextKey=n;const t=s("button","secondary","Cancel");t.dataset.action="cancel",h.append(e,t)}else if(e.deleteKey===n){const e=s("button","cx-confirm-delete","Delete");e.dataset.action="confirm-delete",e.dataset.contextKey=n;const t=s("button","secondary","Cancel");t.dataset.action="cancel",h.append(e,t)}else{const e=s("button","secondary cx-row-more","•••");e.dataset.action="select",e.dataset.contextKey=n,e.setAttribute("aria-label",`Open ${t.name} details`),h.append(e)}o.append(i,r,l,d,p,u,h),c.append(o)}),E("empty").hidden=a.length>0,E("count").textContent=`${a.length} tag${1===a.length?"":"s"}`;const i=n.find(t=>e.options.contextKey(t.name)===e.selectedKey);!function(t,n,a){const s=E("detail");if(!t)return s.hidden=!0,void s.replaceChildren();s.hidden=!1;const c=e.deleteKey===a,i=n.map(t=>{const n=e.options.serverStatus(t),a=(t.tags||[]).map(e=>`<span>${o(e)}</span>`).join("");return`<div class="cx-detail-host"><div class="cx-detail-alias"><i class="${n.tone}"></i><span><strong>${o(t.alias)}</strong><small>${o(t.notes||"No notes")}</small></span></div><code>${o(t.username)}@${o(t.hostname)}:${t.port}</code><div class="cx-detail-labels">${a}</div><span class="cx-detail-status ${n.tone}"><b>${o(n.short)}</b><small>${o(n.meta)}</small></span><span class="cx-detail-seen">${o(function(e){const t=Date.parse(e||"");if(!Number.isFinite(t))return"—";const n=Math.max(0,Math.floor((Date.now()-t)/6e4));if(n<1)return"Just now";if(n<60)return`${n}m ago`;const a=Math.floor(n/60);return a<24?`${a}h ago`:`${Math.floor(a/24)}d ago`}(t.last_test_at))}</span><div class="cx-detail-actions"><button type="button" class="secondary" data-action="test-host" data-id="${o(t.id)}">Test</button><button type="button" class="secondary" data-detail-contexts data-id="${o(t.id)}" aria-haspopup="dialog" aria-expanded="false" title="Edit host tags">+</button><button type="button" class="secondary" data-action="edit-host" data-id="${o(t.id)}">Edit</button></div></div>`}).join("");s.innerHTML=`<header class="cx-detail-head"><div class="cx-detail-title"><span class="cx-stack-icon" aria-hidden="true">◇</span><div><h3>${o(t.name)} <small class="${n.length?"active":""}">${n.length?"Active":"Empty"}</small></h3><p>Reusable host tag</p></div></div><div class="cx-detail-buttons">${c?`<button type="button" class="secondary" data-action="cancel">Cancel</button><button type="button" class="danger" data-action="confirm-delete" data-context-key="${o(a)}">Delete now</button>`:`<button type="button" class="secondary" data-action="rename" data-context-key="${o(a)}">Rename</button><button type="button" class="secondary" data-action="manage-hosts" data-context-key="${o(a)}">Manage hosts</button><button type="button" class="danger" data-action="delete" data-context-key="${o(a)}">Delete</button>`}</div></header><div class="cx-detail-stats"><span><strong>${n.length}</strong><small>host${1===n.length?"":"s"}</small></span><span><small>Used in</small><strong>${n.length?"Host filtering":"Not assigned"}</strong></span><span><small>Updated</small><strong>${o(T(n))}</strong></span></div><section class="cx-detail-hosts"><header><h4>Hosts with this tag</h4></header><div class="cx-detail-host-head"><span>Alias</span><span>Endpoint</span><span>Tags</span><span>Status</span><span>Last seen</span><span>Actions</span></div>${i||'<div class="cx-detail-empty">No hosts use this tag yet. Use Manage hosts to add one.</div>'}</section>`}(i,i?q(i):[],e.selectedKey)}async function N(t){if(!e.busy){e.busy=!0,e.container.setAttribute("aria-busy","true");try{await t()}catch(t){e.options.toast(t.message||String(t),!0)}finally{e.busy=!1,e.container.removeAttribute("aria-busy")}}}async function D(){const t=E("create"),n=t.value.trim();if(!n)return t.focus();const a=await e.options.api("/api/contexts",{method:"POST",body:{name:n}});t.value="",E("creator").hidden=!0,await e.options.refresh(),e.selectedKey=e.options.contextKey(a.name),M(),e.options.toast(`Tag “${a.name}” created`)}async function H(t){const n=K(t),a=e.container.querySelector("[data-cm-rename]"),s=a?.value.trim();if(!n||!s||s===n.name)return e.editKey="",M();const o=e.options.getState().hostContext===t,c=await e.options.api("/api/contexts",{method:"PUT",body:{name:n.name,new_name:s}});await e.options.refresh(),o&&e.options.setHostContext(e.options.contextKey(c.name)),e.selectedKey===t&&(e.selectedKey=e.options.contextKey(c.name)),e.editKey="",M(),e.options.toast(`Renamed to “${c.name}”`)}function I(){e.options.showView("servers"),enhanceConnections(e.options)}export function openContextManager(t){a(),t.showView("contexts"),e||function(t){const n=document.querySelector("#contextManagerMount");n.className="cx-library",n.innerHTML='\n <section class="cx-library-surface">\n <header class="cx-library-toolbar"><button type="button" class="cx-assignment-guide" data-action="open-connections"><span class="cx-guide-icon" aria-hidden="true">↗</span><span><strong>Assign tags to hosts</strong><small>Select hosts in Connections and apply tags.</small></span><b aria-hidden="true">›</b></button><div class="cx-library-tools"><label class="cx-library-search"><span class="sr-only">Search tags</span><input data-cm="search" type="search" placeholder="Search tags…" aria-label="Search tags"></label></div></header>\n <div class="cx-create-context" data-cm="creator" hidden><input data-cm="create" maxlength="40" placeholder="New tag name" aria-label="New tag name"><button type="button" data-action="create">Create</button><button type="button" class="secondary" data-action="cancel-create">Cancel</button></div>\n <div class="cx-context-table" role="table" aria-label="Tag library">\n <div class="cx-context-header" role="row"><span role="columnheader">Name</span><span role="columnheader">Description</span><span role="columnheader">Hosts</span><span role="columnheader">Usage</span><span role="columnheader">Tag</span><span role="columnheader">Updated</span><span role="columnheader">Actions</span></div>\n <div data-cm="rows" role="rowgroup"></div>\n </div>\n <div class="cx-context-empty" data-cm="empty"><strong>No tags found</strong><span>Create a tag from the page header or clear your search.</span></div>\n <footer class="cx-library-footer"><span data-cm="count"></span></footer>\n </section>\n <section class="cx-context-detail" data-cm="detail" hidden></section>',e={container:n,options:t,query:"",editKey:"",deleteKey:"",selectedKey:"",busy:!1},E("create").addEventListener("keydown",e=>{"Enter"===e.key&&(e.preventDefault(),N(D))}),E("search").addEventListener("input",t=>{e.query=t.target.value,M()}),n.addEventListener("keydown",t=>{t.target.matches("[data-cm-rename]")&&("Enter"===t.key?(t.preventDefault(),N(()=>H(t.target.dataset.cmRename))):"Escape"===t.key&&(e.editKey="",M()))}),n.addEventListener("click",t=>{const n=t.target.closest("button");if(!n)return;const a=n.dataset.action,s=n.dataset.contextKey;if("create"===a)N(D);else if("cancel-create"===a)E("creator").hidden=!0,E("create").value="";else if("open-connections"===a)I();else if("select"===a)e.selectedKey=s,e.editKey="",e.deleteKey="",M();else if("manage-hosts"===a)e.options.setHostContext(s),I();else if("test-host"===a)N(async()=>{const t=await e.options.api(`/api/servers/${n.dataset.id}/test`,{method:"POST"});e.options.toast(t.ok?`Connected in ${t.latency_ms} ms`:t.message,!t.ok),await e.options.refresh(),M()});else if("edit-host"===a){const t=e.options.getState().servers.find(e=>e.id===n.dataset.id);t&&e.options.openServer(t)}else n.matches("[data-detail-contexts]")?$(e.options,[n.dataset.id],"detail",n.dataset.id):"rename"===a?(e.editKey=s,e.deleteKey="",M(),setTimeout(()=>e.container.querySelector("[data-cm-rename]")?.select(),0)):"save-rename"===a?N(()=>H(s)):"delete"===a?(e.deleteKey=s,e.editKey="",M()):"confirm-delete"===a?N(()=>async function(t){const n=K(t);if(!n)return;const a=e.options.getState().hostContext===t;await e.options.api("/api/contexts",{method:"DELETE",body:{name:n.name}}),await e.options.refresh(),a&&e.options.setHostContext("*"),e.selectedKey===t&&(e.selectedKey=""),e.deleteKey="",M(),e.options.toast(`Tag “${n.name}” deleted`)}(s)):"cancel"===a&&(e.editKey="",e.deleteKey="",M())})}(t),e.options=t,M()}export function focusContextCreation(){e&&(E("creator").hidden=!1,requestAnimationFrame(()=>E("create").focus()))}export function syncContextUI(n){m?.open&&(m.options=n,b()),t&&(t.options=n,L()),e&&(e.options=n,M())}
@@ -0,0 +1 @@
1
+ <!doctype html><html lang=en><meta charset=utf-8><meta content="width=device-width,initial-scale=1" name=viewport><title>SSH Server Manager</title><link href=/assets/styles.css rel=stylesheet><script defer src=/assets/app.js></script><div class=app-shell><aside class=sidebar><div class=brand><span class=brand-mark aria-hidden=true><svg viewBox="0 0 24 24"><path d="M7 8l4 4-4 4"/><path d="M13 16h4"/></svg></span><div><strong>SSH Manager</strong><span>Local control plane</span></div></div><nav aria-label="Primary navigation" class=primary-nav><button type=button class="nav-item active" data-view=workspace aria-current=page><svg viewBox="0 0 24 24" aria-hidden=true><path d="M3 6.5h6l2 2h10v9.5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 9h18"/></svg><span>Workspace</span></button><button type=button class=nav-item data-view=servers><svg viewBox="0 0 24 24" aria-hidden=true><rect height=6 rx=2 width=16 x=4 y=4 /><rect height=6 rx=2 width=16 x=4 y=14 /><path d="M8 7h.01M8 17h.01"/></svg><span>Connections</span><span class=nav-count id=navServerCount>0</span></button><button type=button class=nav-item data-view=contexts><svg viewBox="0 0 24 24" aria-hidden=true><path d="M4 7h10l6 5-6 5H4z"/><circle cx=8 cy=12 r=1 /></svg><span>Tags</span><span class=nav-count id=navContextCount>0</span></button><button type=button class=nav-item data-view=credentials><svg viewBox="0 0 24 24" aria-hidden=true><circle cx=8 cy=15 r=4 /><path d="M11 12l8-8M16 7l2 2M14 9l2 2"/></svg><span>Credentials</span><span class=nav-count id=navCredentialCount>0</span></button></nav><section class=host-navigation id=hostNavigation aria-labelledby=hostNavigationTitle><div class=sidebar-section-heading><span id=hostNavigationTitle>Hosts</span><button type=button class=sidebar-icon-button id=sidebarAddServerButton aria-label="Add server" title="Add server">+</button></div><label class=sidebar-search><svg viewBox="0 0 24 24" aria-hidden=true><circle cx=11 cy=11 r=7 /><path d="M16 16l4 4"/></svg><input id=hostSearchInput type=search autocomplete=off aria-label="Filter hosts" placeholder="Filter hosts"></label><div class=workspace-host-list id=workspaceHostList></div><p class=sidebar-empty id=emptyWorkspaceHosts>No hosts match this view.</section><section class=label-navigation id=labelNavigation aria-labelledby=labelNavigationTitle><div class=sidebar-section-heading><span id=labelNavigationTitle>Tags</span><span class=sidebar-heading-actions><button type=button class=sidebar-text-button id=sidebarEditContextsButton>Edit</button><button type=button class=sidebar-icon-button id=sidebarAddContextButton aria-label="Create tag" title="Create tag">+</button></span></div><form id=sidebarContextForm class=sidebar-context-form hidden><input id=sidebarContextName autocomplete=off placeholder="New tag name" aria-label="New tag name" maxlength=40><button type=submit aria-label="Create tag">Add</button></form><div class=sidebar-context-list id=sidebarContextList></div><p class=sidebar-empty id=emptySidebarContexts>No tags yet.</section><footer class=sidebar-footer><div class=session-status id=systemStatus><span></span><div><strong>Local session</strong><small>Starting…</small></div></div><button type=button class=sidebar-icon-button id=refreshButton aria-label="Refresh data" title="Refresh data"><svg viewBox="0 0 24 24" aria-hidden=true><path d="M20 6v5h-5"/><path d="M4 18v-5h5"/><path d="M6.1 9a7 7 0 0 1 11.6-2.6L20 11M4 13l2.3 4.6A7 7 0 0 0 17.9 15"/></svg></button></footer></aside><div class=app-content><header class=view-header><div class=view-heading><p id=viewEyebrow>REMOTE WORKSPACE<h1 id=viewTitle>Workspace</h1><span id=viewDescription>Browse and reference remote files without opening a shell.</span></div><div class=view-actions><label id=contextFilterControl class=context-control><span class=sr-only>Host tag</span><select id=hostContextSelect aria-label="Filter hosts by tag"><option value=*>All hosts</select></label><label class=theme-control><span class=sr-only>Color theme</span><select id=themeSelect aria-label="Color theme"><option value=system>System theme<option value=light>Light theme<option value=dark>Dark theme<option value=contrast>High contrast</select></label><div class=header-action-group id=workspaceHeaderActions hidden><span class=header-action-group id=workspaceActiveActions hidden><button type=button class="button subtle-button" id=workspaceTestButton>Test</button><button type=button class="button subtle-button" id=workspaceCopyCommandButton>Copy SSH command</button><button type=button class="button secondary" id=workspaceEditButton>Edit host</button></span><button type=button class="button primary-button" id=workspaceAddHeaderButton><span aria-hidden=true>+</span> Add host</button></div><div class=header-action-group id=serverHeaderActions hidden><button type=button class="button subtle-button" id=importButton>Import config</button><button type=button class="button primary-button" id=addServerButton><span aria-hidden=true>+</span> Add host</button></div><div class=header-action-group id=contextHeaderActions hidden><button type=button class="button primary-button" id=createContextButton><span aria-hidden=true>+</span> Create tag</button></div><div class=header-action-group id=credentialHeaderActions hidden><button type=button class="button primary-button" id=addCredentialButton>Add credential <span aria-hidden=true>⌄</span></button></div></div></header><main class=view-stage><section class="view-panel workspace-view" id=fileBrowserPanel data-view-panel=workspace aria-labelledby=viewTitle><div class=mobile-host-controls><label>Host<select id=fileServerSelect aria-label="Host to browse" disabled><option value="">Choose a host…</select></label></div><div class=workspace-zero-state id=fileBrowserEmpty><div class=workspace-empty-hero><span class=empty-terminal aria-hidden=true><i></i><svg viewBox="0 0 64 64"><path d="M17 20l14 12-14 12M34 44h13"/></svg></span><h2>No active host</h2><p>Select a host from the left sidebar to access its workspace.</div><div class=workspace-start-actions><button type=button class=start-card id=emptyChooseHostButton><span class=start-icon><svg viewBox="0 0 24 24"><path d="M4 6h6l2 2h8v10H4z"/><path d="M8 12h8"/></svg></span><span><strong>Open recent host</strong><small>Quickly access a recent connection</small></span><b aria-hidden=true>›</b></button><button type=button class=start-card id=emptyAddServerButton><span class="start-icon plus">+</span><span><strong>Add host</strong><small>Connect to a new server</small></span><b aria-hidden=true>›</b></button><button type=button class=start-card id=emptyImportButton><span class=start-icon><svg viewBox="0 0 24 24"><path d="M12 16V4M8 8l4-4 4 4"/><path d="M5 14v5h14v-5"/></svg></span><span><strong>Import config</strong><small>Import SSH config or a host list</small></span><b aria-hidden=true>›</b></button></div><section class=recent-hosts-card><header><h3>Recent hosts</h3></header><div class=recent-host-rows id=recentHostRows></div><button type=button class=recent-hosts-more id=viewAllHostsButton>View all hosts <span aria-hidden=true>↗</span></button></section></div><div class=remote-browser id=fileBrowserContent hidden><div class=remote-host-bar><span class=remote-host-icon aria-hidden=true><svg viewBox="0 0 24 24"><rect height=14 rx=3 width=16 x=4 y=5 /><path d="M8 10l3 2-3 2M13 15h3"/></svg></span><div class=file-connection-label id=fileConnectionLabel></div><span class="compact-status neutral" id=fileHostStatus><i></i>Not tested</span></div><div class=file-command-bar><div class=file-nav-buttons><button type=button class=toolbar-button id=fileHomeButton aria-label="Home directory" title="Home directory"><svg viewBox="0 0 24 24"><path d="M3 11l9-8 9 8"/><path d="M5 10v10h14V10M9 20v-6h6v6"/></svg></button><button type=button class=toolbar-button id=fileUpButton aria-label="Parent directory" title="Parent directory" disabled><svg viewBox="0 0 24 24"><path d="M12 19V5M6 11l6-6 6 6"/></svg></button></div><nav aria-label="Remote path" class=file-breadcrumbs id=fileBreadcrumbs></nav><div class=file-command-actions><button type=button class="toolbar-button text-toolbar-button" id=copyDirectoryReferenceButton disabled><svg viewBox="0 0 24 24"><rect height=11 rx=2 width=11 x=8 y=8 /><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg><span>Copy reference</span></button><button type=button class="toolbar-button text-toolbar-button" id=fileRefreshButton aria-label="Refresh directory" title="Refresh directory"><svg viewBox="0 0 24 24"><path d="M20 6v5h-5"/><path d="M4 18v-5h5"/><path d="M6.1 9a7 7 0 0 1 11.6-2.6L20 11M4 13l2.3 4.6A7 7 0 0 0 17.9 15"/></svg><span>Refresh</span></button></div></div><form id=filePathForm class=file-path-form><label><span>Path</span><input id=filePathInput autocomplete=off placeholder=/home/user/project spellcheck=false></label><button type=submit class=button>Go</button></form><div class=file-filter-bar><label class=file-search><svg viewBox="0 0 24 24" aria-hidden=true><circle cx=11 cy=11 r=7 /><path d="M16 16l4 4"/></svg><input id=fileSearchInput type=search autocomplete=off aria-label="Filter files" placeholder="Filter this folder"></label><label class=show-hidden-toggle><input id=fileShowHidden type=checkbox> Show hidden</label><button type=button class="toolbar-button text-toolbar-button" id=copyVisibleReferencesButton disabled>Copy visible</button><span class=file-list-status id=fileListStatus aria-live=polite role=status></span></div><div class=file-browser-error id=fileBrowserError hidden role=alert><span class=error-icon aria-hidden=true>!</span><div><strong>Could not load this directory</strong><span id=fileBrowserErrorMessage></span></div><button type=button class="button secondary small" id=fileRetryButton>Retry</button></div><div class=file-loading id=fileLoading hidden aria-label="Loading remote files"><span></span><span></span><span></span></div><div class=file-table-wrap id=fileTableWrap hidden><table class=file-table><thead><tr><th><button type=button class="sort-button active" data-sort=name>Name <span aria-hidden=true>↑</span></button><th><button type=button class=sort-button data-sort=size>Size <span aria-hidden=true></span></button><th class=file-modified-column><button type=button class=sort-button data-sort=modified>Modified <span aria-hidden=true></span></button><th class=file-permissions-column>Permissions<th><span class=sr-only>Actions</span><tbody id=fileRows></table></div><div class=file-list-more id=fileListMore hidden><span id=fileListMoreStatus></span><button type=button class="button secondary small" id=fileLoadMoreButton>Show more</button></div><div class=file-list-empty id=fileListEmpty hidden><strong id=fileListEmptyTitle>This directory is empty</strong><span id=fileListEmptyMessage>There is nothing to display here.</span></div></div></section><section class="view-panel management-view" id=serversView data-view-panel=servers hidden><div class=connection-summary aria-label="Connection overview"><article><span class="summary-icon hosts"><svg viewBox="0 0 24 24"><rect height=6 rx=2 width=14 x=5 y=4 /><rect height=6 rx=2 width=14 x=5 y=14 /><path d="M9 7h.01M9 17h.01"/></svg></span><div><small>Total hosts</small><strong id=summaryTotalHosts>0</strong><p>Across all tags</div></article><article><span class="summary-icon reachable"><svg viewBox="0 0 24 24"><path d="M12 3l8 4v5c0 5-3.4 8.2-8 9-4.6-.8-8-4-8-9V7z"/><path d="M9 12l2 2 4-5"/></svg></span><div><small>Reachable hosts</small><strong id=summaryReachableHosts>0</strong><p id=summaryReachablePercent>No fresh checks</div></article><article><span class="summary-icon credentials"><svg viewBox="0 0 24 24"><circle cx=8 cy=15 r=4 /><path d="M11 12l8-8M16 7l2 2"/></svg></span><div><small>Credentials</small><strong id=summaryCredentialCount>0</strong><p>Stored securely</div></article><article><span class="summary-icon contexts"><svg viewBox="0 0 24 24"><path d="M12 3l8 4-8 4-8-4zM4 12l8 4 8-4M4 17l8 4 8-4"/></svg></span><div><small>Tags</small><strong id=summaryContextCount>0</strong><p>Host labels</div></article></div><div class=connection-toolbar-mount id=connectionToolbarMount></div><div class=management-surface><div class=surface-heading><div><h2>Managed connections <span><b id=serverCount>0</b> hosts</span></h2><p class=sr-only>Select hosts to test or organize them in bulk.</div><div class=connection-meta><span class=sr-only><b id=credentialCount>0</b> credentials</span><code id=configPath>—</code><button type=button class="button secondary small" id=exportConnectionsButton><span aria-hidden=true>⇩</span> Export</button></div></div><div class=table-wrap><table class="management-table connection-table"><thead><tr><th>Alias<th>Endpoint<th>Tags<th>Credential<th>Route / proxy<th>Status<th><span class=sr-only>Actions</span><tbody id=serverRows></table></div><p class=empty id=emptyServers>No managed servers yet. Import your SSH config or add one.</div></section><section class="view-panel context-view" id=contextsView data-view-panel=contexts hidden><div id=contextManagerMount aria-live=polite>Loading tags…</div></section><section class="view-panel management-view" id=credentialsView data-view-panel=credentials hidden><div class=security-note><span aria-hidden=true><svg viewBox="0 0 24 24"><path d="M12 3l8 4v5c0 5-3.4 8.2-8 9-4.6-.8-8-4-8-9V7z"/><path d="M9 12l2 2 4-5"/></svg></span><div class=security-copy><strong>Credentials are encrypted and never shared.</strong><p>Secrets remain in your operating-system vault and never enter browser storage.</div></div><div class=management-surface><div class="surface-heading credential-heading"><div><h2>Credential profiles</h2><p><span id=credentialProfileCount>0</span> credentials</div><div class=credential-tools><label class=surface-search><svg viewBox="0 0 24 24"><circle cx=11 cy=11 r=7 /><path d="M16 16l4 4"/></svg><input id=credentialSearchInput type=search autocomplete=off aria-label="Search credentials" placeholder="Search credentials…"></label><select id=credentialKindFilter aria-label="Filter credential type"><option value=*>All types<option value=password>Password<option value=key>Key<option value=agent>OpenSSH / agent</select></div></div><div class=table-wrap><table class="management-table credential-table"><thead><tr><th>Name<th>Type<th>Status<th>Secret state<th>Key path<th>Last checked<th><span class=sr-only>Actions</span><tbody id=credentialRows></table></div><p class=empty id=emptyCredentials>No credential profiles configured.</div><section class=reauth-card><header><div><h2>Local reauthentication</h2><p>Unlock local credentials before revealing a stored secret.</div><div class=row-actions><button class="button secondary small" id=enrollPasskeyButton>Enroll passkey</button><button class="button secondary small" id=enrollMasterButton>Set fallback password</button></div></header><div class=reauth-status><span aria-hidden=true>✓</span><div><strong id=authStatusTitle>Checking protection…</strong><small id=authDescription>Checking protection…</small></div></div></section></section></main></div></div><div id=toast aria-live=polite role=status></div><dialog id=serverDialog><form id=serverForm method=dialog><div class=dialog-heading><h2 id=serverDialogTitle>Add server</h2><button type=button class="close-dialog icon-button" aria-label=Close>×</button></div><input id=serverId type=hidden><label>Alias<input id=serverAlias required pattern=[A-Za-z0-9][A-Za-z0-9._-]{0,62}></label><div class=two-columns><label>Hostname or IP<input id=serverHostname required></label><label>Port<input id=serverPort type=number required max=65535 min=1 value=22></label></div><label>Username<input id=serverUsername required></label><label>Credential<div class=credential-picker><select id=serverCredential><option value="">OpenSSH default / agent</select><button type=button class="secondary small" id=newServerCredentialButton>+ New</button></div></label><fieldset class=inline-composer hidden id=serverCredentialComposer><legend>New credential</legend><p class=hint>Create it here and it will be selected for this server automatically.</p><label>Label<input id=serverCredentialNewLabel autocomplete=off maxlength=100></label><label>Authentication type<select id=serverCredentialNewKind><option value=password>Password<option value=key>Private key file<option value=agent>OpenSSH default / ssh-agent</select></label><label id=serverCredentialNewSecretField>Password<input id=serverCredentialNewSecret type=password autocomplete=new-password></label><label id=serverCredentialNewKeyPathField hidden>Private-key path<input id=serverCredentialNewKeyPath placeholder=~/.ssh/id_ed25519></label><label id=serverCredentialNewPassphraseField hidden>Key passphrase (optional)<input id=serverCredentialNewPassphrase type=password autocomplete=new-password></label><div class=inline-composer-actions><button type=button class=secondary id=cancelServerCredentialButton>Cancel</button><button type=button id=saveServerCredentialButton>Save and use</button></div></fieldset><label>ProxyJump aliases<input id=serverProxy placeholder="jump-one, jump-two"></label><label>Tags<input id=serverTags placeholder="production, client-a" maxlength=839></label><label>Notes<textarea id=serverNotes rows=3></textarea></label><div class=dialog-actions><button type=button class="secondary close-dialog">Cancel</button><button type=submit>Save server</button></div></form></dialog><dialog id=credentialDialog><form id=credentialForm method=dialog><div class=dialog-heading><h2 id=credentialDialogTitle>Add credential</h2><button type=button class="close-dialog icon-button" aria-label=Close>×</button></div><input id=credentialId type=hidden><label>Label<input id=credentialLabel required maxlength=100></label><label>Authentication type<select id=credentialKind><option value=password>Password<option value=key>Private key file<option value=agent>OpenSSH default / ssh-agent</select></label><label id=secretField>Password<input id=credentialSecret type=password autocomplete=new-password></label><label id=keyPathField hidden>Private-key path<input id=credentialKeyPath placeholder=~/.ssh/id_ed25519></label><label id=passphraseField hidden>Key passphrase (optional)<input id=credentialPassphrase type=password autocomplete=new-password></label><label id=clearPassphraseField hidden class=checkbox-label><input id=credentialClearPassphrase type=checkbox> Remove the stored passphrase</label><p class=hint id=credentialEditHint hidden>Leave secret fields empty to keep their existing values.<div class=dialog-actions><button type=button class="secondary close-dialog">Cancel</button><button type=submit>Save credential</button></div></form></dialog><dialog id=revealDialog><div class=dialog-heading><h2>Revealed secret</h2><button type=button class="icon-button close-reveal" aria-label=Close>×</button></div><p>This value will be cleared from the page in <strong id=revealCountdown>15</strong> seconds.<div class=secret-box><code id=revealedSecret></code><button class=secondary id=copySecretButton>Copy</button></div><div class=dialog-actions><button type=button class="secondary close-reveal">Close now</button></div></dialog><dialog id=masterDialog><form id=masterForm method=dialog><div class=dialog-heading><h2 id=masterDialogTitle>Local authentication</h2><button type=button class="icon-button cancel-master" aria-label=Close>×</button></div><label>Master password<input id=masterPassword type=password autocomplete=current-password minlength=12 required></label><label id=masterConfirmField hidden>Confirm master password<input id=masterPasswordConfirm type=password autocomplete=new-password minlength=12></label><div class=dialog-actions><button type=button class="secondary cancel-master">Cancel</button><button type=submit>Authenticate</button></div></form></dialog>
@@ -0,0 +1 @@
1
+ :root{color-scheme:light;--ink:#172033;--muted:#657086;--faint:#8b96aa;--line:#dde3ed;--line-strong:#cbd4e2;--canvas:#f3f6fb;--surface:#fff;--surface-soft:#f7f9fc;--control:#edf1f7;--control-hover:#e4e9f2;--row-hover:#f4f7fc;--sidebar:#09111e;--sidebar-soft:#101a2a;--sidebar-text:#eef2fa;--sidebar-muted:#a7b1c4;--sidebar-faint:#70809a;--accent:#6476e8;--accent-hover:#5364d7;--accent-soft:#eef0ff;--accent-border:#bdc6ff;--focus-ring:#6476e840;--danger:#d74d53;--danger-soft:#fff0f1;--danger-border:#f2c1c5;--success:#159a68;--success-soft:#e5f7ef;--success-border:#b9e8d2;--warning:#b07708;--warning-soft:#fff4d6;--warning-border:#ebd29a;--violet:#815fd1;--violet-soft:#f2edff;--on-accent:#fff;--overlay:#050a14b3;--shadow-dialog:0 30px 100px #04091459;font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}:root[data-theme=dark]{color-scheme:dark;--ink:#f2f5fb;--muted:#9aa7bc;--faint:#687891;--line:#1c2a3e;--line-strong:#2a3a53;--canvas:#07101d;--surface:#0b1626;--surface-soft:#101c2e;--control:#162338;--control-hover:#1d2c44;--row-hover:#111f33;--sidebar:#07101b;--sidebar-soft:#0d1929;--sidebar-text:#f3f6fb;--sidebar-muted:#a3aec1;--sidebar-faint:#657590;--accent:#8898ff;--accent-hover:#a1adff;--accent-soft:#18234b;--accent-border:#40518f;--focus-ring:#8898ff47;--danger:#ff6f76;--danger-soft:#351d2a;--danger-border:#64303d;--success:#41d395;--success-soft:#10352e;--success-border:#1d5a48;--warning:#f2bd55;--warning-soft:#382d19;--warning-border:#65502a;--violet:#ab8bfa;--violet-soft:#292140;--on-accent:#07101d;--overlay:#00040ccc;--shadow-dialog:0 30px 100px #00000094}:root[data-theme=contrast]{color-scheme:dark;--ink:#fff;--muted:#e2e8f2;--faint:#b5c0d0;--line:#53627a;--line-strong:#8996aa;--canvas:#000711;--surface:#06101c;--surface-soft:#0d1929;--control:#16263a;--control-hover:#253a54;--row-hover:#102238;--sidebar:#00050c;--sidebar-soft:#0b1725;--sidebar-text:#fff;--sidebar-muted:#edf2fa;--sidebar-faint:#b7c2d2;--accent:#a9b5ff;--accent-hover:#ced4ff;--accent-soft:#17295a;--accent-border:#8b9cf6;--focus-ring:#ffdd54b3;--danger:#ff9a9f;--danger-soft:#3b151d;--danger-border:#d06068;--success:#7ce9bb;--success-soft:#073c2d;--success-border:#44a57e;--warning:#ffd76f;--warning-soft:#3d3006;--warning-border:#b58b23;--on-accent:#000}*{box-sizing:border-box}[hidden]{display:none!important}body,html{min-height:100%;margin:0}body{background:var(--canvas);color:var(--ink);font-size:14px;overflow:hidden}button,input,select,textarea{font:inherit}button{cursor:pointer;border:0}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid var(--focus-ring);outline-offset:2px}button:disabled{cursor:default;opacity:.45}h1,h2,h3,h4,p{margin-top:0}svg{display:block}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border:0;width:1px;height:1px;padding:0;position:absolute;overflow:hidden}.button,dialog button:not(.icon-button){background:var(--accent);min-height:40px;color:var(--on-accent);border:1px solid #0000;border-radius:8px;padding:9px 15px;font-size:12px;font-weight:750;transition:background .15s,border-color .15s,transform .15s}.button:hover,dialog button:not(.icon-button):hover{background:var(--accent-hover)}.button:active{transform:translateY(1px)}.button.primary-button{background:linear-gradient(135deg,var(--accent),color-mix(in srgb,var(--accent) 78%,#6c56ef))}.button.secondary,dialog button.secondary{border-color:var(--line-strong);color:var(--ink);background:0 0}.button.secondary:hover,.row-menu button:hover,dialog button.secondary:hover{background:var(--control)}.button.subtle-button,.row-menu summary:hover,.row-menu[open] summary{background:var(--control);color:var(--ink)}.button.subtle-button:hover{background:var(--control-hover)}.button.small,dialog button.small{border-radius:7px;min-height:32px;padding:6px 10px;font-size:10px}.app-shell{grid-template-columns:304px minmax(0,1fr);width:100%;height:100dvh;display:grid}.brand strong,.sidebar{color:var(--sidebar-text)}.sidebar{background:radial-gradient(circle at 50% 42%,#41568917,transparent 45%),var(--sidebar);border-right:1px solid #8499bc29;flex-direction:column;min-width:0;padding:18px 14px 13px;display:flex;overflow:hidden}.brand{align-items:center;gap:12px;padding:0 6px 18px;display:flex}.brand-mark{width:40px;height:40px;color:var(--accent);background:linear-gradient(145deg,#6879ff3b,#4350aa1f);border:1px solid #7e91ff57;border-radius:10px;flex:0 0 40px;place-items:center;display:grid}.brand-mark svg,.nav-item svg,.sidebar-search svg{fill:none;stroke:currentColor;stroke-width:1.8px;stroke-linecap:round;stroke-linejoin:round}.brand-mark svg{width:21px;height:21px}.brand div{gap:3px;min-width:0;display:grid}.brand strong{letter-spacing:-.02em;font-size:16px}.brand span:not(.brand-mark){color:var(--sidebar-faint);font-size:11px}.primary-nav{border-bottom:1px solid #92a3bf1f;gap:3px;padding:4px 0 14px;display:grid}.nav-count,.nav-item{color:var(--sidebar-muted)}.nav-item{text-align:left;background:0 0;border-radius:8px;grid-template-columns:20px minmax(0,1fr) auto;align-items:center;gap:10px;width:100%;min-height:41px;padding:8px 11px;font-size:12px;font-weight:680;display:grid;position:relative}.nav-item:hover{color:var(--sidebar-text);background:#ffffff0b}.nav-item.active{color:var(--accent);background:linear-gradient(100deg,#5364d354,#3a4aa92e)}.nav-item svg{width:18px;height:18px}.nav-count{text-align:center;background:#ffffff12;border-radius:999px;min-width:22px;padding:2px 6px;font-size:9px}.nav-item.active .nav-count{color:#bdc6ff;background:#7384ff3d}.host-navigation{flex-direction:column;flex:auto;min-height:218px;padding-top:15px;display:flex;overflow:hidden}.label-navigation{border-top:1px solid #92a3bf1f;flex:0 auto;max-height:190px;padding:10px 0 9px;overflow:hidden}body[data-view=contexts] .label-navigation{border-top:0;flex:1;max-height:none;padding-top:18px}.sidebar-section-heading{min-height:28px;color:var(--sidebar-muted);letter-spacing:.12em;text-transform:uppercase;justify-content:space-between;align-items:center;padding:0 5px 7px 8px;font-size:10px;font-weight:820;display:flex}.sidebar-heading-actions{align-items:center;gap:4px;display:flex}.sidebar-text-button{color:var(--sidebar-muted);background:0 0;border-radius:5px;padding:3px 5px;font-size:9px;font-weight:700}.sidebar-text-button:hover{color:var(--sidebar-text);background:#ffffff0f}.sidebar-icon-button{width:30px;height:30px;color:var(--sidebar-muted);background:#ffffff0a;border:1px solid #92a3bf1a;border-radius:8px;place-items:center;padding:0;font-size:18px;display:grid}.sidebar-icon-button:hover{color:var(--sidebar-text);background:#ffffff14;border-color:#92a3bf33}.sidebar-icon-button svg{fill:none;stroke:currentColor;stroke-width:1.8px;stroke-linecap:round;stroke-linejoin:round;width:16px;height:16px}.sidebar-search{margin:0 4px 8px;display:block;position:relative}.sidebar-search svg{width:14px;height:14px;color:var(--sidebar-faint);position:absolute;top:10px;left:10px}.sidebar-context-form input,.sidebar-search input{width:100%;height:36px;color:var(--sidebar-text);background:#0e1a2bd6;border:1px solid #8095b92e;border-radius:8px;outline:0;font-size:11px}.sidebar-search input{padding:7px 10px 7px 32px}.sidebar-context-form input::placeholder,.sidebar-search input::placeholder{color:var(--sidebar-faint)}.sidebar-context-form input:focus,.sidebar-search input:focus{border-color:var(--accent-border)}.workspace-host-list{scrollbar-color:#7488ab33 transparent;align-content:start;gap:3px;min-height:0;padding:0 1px 10px;display:grid;overflow-y:auto}.workspace-host-row{border:1px solid #0000;border-radius:8px;grid-template-columns:minmax(0,1fr) 30px;align-items:center;padding:6px 5px 6px 7px;display:grid}.workspace-host-row:hover{background:#ffffff09}.workspace-host-row.active{background:linear-gradient(100deg,#4859c64d,#33429733);border-color:#6f81f875}.workspace-host{min-width:0;color:var(--sidebar-text);text-align:left;background:0 0;grid-template-columns:8px minmax(0,1fr);align-items:center;gap:10px;padding:0;display:grid}.host-status-dot{background:var(--sidebar-faint);border-radius:50%;width:7px;height:7px;display:inline-block;box-shadow:0 0 0 3px #70809a21}.host-status-dot.ok,.session-status.ok>span{background:var(--success);box-shadow:0 0 0 3px color-mix(in srgb,var(--success) 14%,transparent)}.host-status-dot.failed{background:var(--danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--danger) 14%,transparent)}.host-status-dot.stale{background:var(--warning);box-shadow:0 0 0 3px color-mix(in srgb,var(--warning) 14%,transparent)}.host-copy,.session-status div{gap:2px;min-width:0;display:grid}.host-copy strong{color:inherit;font-size:11px;font-weight:750}.host-copy small{color:var(--sidebar-faint);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:8px}.workspace-host-context{width:27px;height:27px;color:var(--sidebar-muted);letter-spacing:1px;background:0 0;border-radius:7px;padding:0;font-size:10px}.workspace-host-context:hover{color:var(--sidebar-text);background:#ffffff14}.workspace-host-context.empty{color:var(--sidebar-faint);border:1px dashed #8b9ab43d;font-size:15px}.workspace-host-tags{grid-column:1/-1;align-items:center;gap:4px;min-width:0;padding:5px 0 0 18px;display:flex;overflow:hidden}.host-tag,.host-tag-more{max-width:88px;min-height:18px;color:var(--sidebar-muted);text-overflow:ellipsis;white-space:nowrap;background:#7a8bb224;border-radius:4px;padding:2px 6px;font-size:8px;font-weight:650;overflow:hidden}.host-tag-more{color:var(--sidebar-faint);background:0 0;padding-inline:3px}.host-tag:hover{color:#c7ceff;background:#6f81f840}.sidebar-context-list{gap:2px;max-height:142px;display:grid;overflow-y:auto}body[data-view=contexts] .sidebar-context-list{max-height:none}.sidebar-context-row{width:100%;min-height:28px;color:var(--sidebar-muted);text-align:left;background:0 0;border-radius:6px;grid-template-columns:8px minmax(0,1fr) auto;align-items:center;gap:9px;padding:5px 8px;font-size:10px;display:grid}.sidebar-context-row.active,.sidebar-context-row:hover{color:var(--sidebar-text);background:#ffffff0d}.sidebar-context-row.active{color:#bdc6ff;background:#4f5fc938}.sidebar-context-row i{background:#56a2ed;border-radius:50%;width:7px;height:7px;box-shadow:0 0 0 3px #56a2ed1f}.sidebar-context-row i.tag-color-1{background:#f0b54d}.sidebar-context-row i.tag-color-2{background:#57d59b}.sidebar-context-row i.tag-color-3{background:#ef6d76}.sidebar-context-row i.tag-color-4{background:#9a76e6}.sidebar-context-row i.tag-color-5{background:#7384f5}.host-copy small,.host-copy strong,.session-status small,.sidebar-context-row span{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.sidebar-context-row b{text-align:right;min-width:18px}.sidebar-context-form{grid-template-columns:minmax(0,1fr) auto;gap:5px;margin:0 3px 7px;display:grid}.sidebar-context-form input{padding:7px 9px}.sidebar-context-form button{background:var(--accent);color:var(--on-accent);border-radius:7px;padding:0 8px;font-size:9px;font-weight:750}.sidebar-context-row b,.sidebar-empty{color:var(--sidebar-faint);font-size:9px}.sidebar-empty{text-align:center;margin:11px 8px;line-height:1.45}.sidebar-footer{border-top:1px solid #92a3bf1f;justify-content:space-between;align-items:center;gap:8px;min-height:56px;padding:12px 4px 0;display:flex}.session-status{grid-template-columns:8px 1fr;align-items:center;column-gap:9px;min-width:0;display:grid}.session-status>span{background:var(--sidebar-faint);border-radius:50%;grid-row:1/3;width:7px;height:7px}.session-status.ok>span{box-shadow:0 0 0 3px color-mix(in srgb,var(--success) 13%,transparent)}.session-status strong{color:var(--sidebar-text);font-size:10px}.session-status small{color:var(--sidebar-faint);font-size:8px}.app-content{grid-template-rows:auto minmax(0,1fr);min-width:0;height:100dvh;display:grid}.view-header{justify-content:space-between;align-items:center;gap:24px;min-height:102px;padding:18px 32px 12px;display:flex}.file-name-cell>div,.view-heading{min-width:0}.view-heading p{color:var(--accent);letter-spacing:.15em;margin:0 0 6px;font-size:9px;font-weight:850}.view-heading h1{letter-spacing:-.04em;margin:0;font-size:28px;font-weight:790;line-height:1.05}.view-heading>span{color:var(--muted);margin-top:7px;font-size:12px;display:block}.header-action-group,.row-actions,.view-actions{flex-wrap:wrap;align-items:center;gap:9px;display:flex}.context-control select,.credential-tools select,.theme-control select{border:1px solid var(--line-strong);background:var(--surface);height:40px;color:var(--muted);border-radius:8px;padding:7px 32px 7px 12px;font-size:11px;font-weight:680}.context-control select{min-width:168px}.view-stage{min-height:0;padding:10px 28px 28px;overflow:auto}.view-panel{width:min(1480px,100%);margin:0 auto}.workspace-view{height:100%;min-height:610px}.connection-meta code,.mobile-host-controls{display:none}.workspace-zero-state{border:1px solid var(--line);background:radial-gradient(circle at 50% 30%,color-mix(in srgb,var(--accent) 6%,transparent),transparent 35%),color-mix(in srgb,var(--surface) 92%,transparent);border-radius:12px;min-height:100%;padding:54px clamp(24px,5vw,82px) 38px;overflow:hidden}.workspace-empty-hero{text-align:center;justify-items:center;display:grid}.empty-terminal{border:1px solid var(--line-strong);background:linear-gradient(180deg,var(--control),var(--surface-soft));width:116px;height:100px;color:var(--faint);border-radius:9px;place-items:center;margin-bottom:28px;display:grid;position:relative}.empty-terminal i{border-bottom:1px solid var(--line);height:23px;position:absolute;top:0;left:0;right:0}.empty-terminal i:before{content:"•••";color:var(--faint);letter-spacing:3px;font-size:12px;position:absolute;top:2px;left:10px}.empty-terminal svg,.start-icon svg{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round}.empty-terminal svg{stroke-width:4px;width:58px;height:58px;margin-top:18px}.workspace-empty-hero h2{letter-spacing:-.03em;margin-bottom:8px;font-size:24px}.workspace-empty-hero p{color:var(--muted);margin-bottom:0;font-size:12px}.workspace-start-actions{grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;width:min(1040px,100%);margin:40px auto 24px;display:grid}.start-card,.start-icon{border-radius:10px;display:grid}.start-card{border:1px solid var(--line);background:var(--surface);min-width:0;min-height:95px;color:var(--ink);text-align:left;grid-template-columns:48px minmax(0,1fr) auto;align-items:center;gap:14px;padding:16px 18px}.start-card:hover{border-color:var(--accent-border);background:var(--row-hover);transform:translateY(-1px)}.start-icon{background:var(--accent-soft);width:48px;height:48px;color:var(--accent);place-items:center;font-size:29px}.start-icon svg{stroke-width:1.7px;width:24px;height:24px}.start-card>span:nth-child(2){gap:6px;min-width:0;display:grid}.start-card strong{font-size:12px}.file-connection-label span,.recent-host-copy small,.start-card small{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;font-size:9px;overflow:hidden}.start-card>b{color:var(--muted);font-size:23px;font-weight:400}.recent-hosts-card{border:1px solid var(--line);background:var(--surface);border-radius:10px;width:min(1040px,100%);margin:0 auto;overflow:hidden}.recent-host-row,.recent-hosts-card>header{border-bottom:1px solid var(--line);align-items:center}.recent-hosts-card>header{min-height:50px;padding:12px 20px;display:flex}.recent-hosts-card h3{margin:0;font-size:12px}.recent-host-row{width:100%;min-height:58px;color:var(--ink);text-align:left;background:0 0;grid-template-columns:34px minmax(170px,1fr) minmax(70px,.5fr) minmax(130px,.7fr) 16px;gap:12px;padding:9px 20px;display:grid}.recent-host-row:hover,.recent-hosts-more:hover{background:var(--row-hover)}.recent-host-icon{background:var(--control);border-radius:7px;width:30px;height:30px}.recent-host-icon:before{content:"";border:1px solid var(--muted);border-radius:2px;width:11px;height:14px;margin:8px auto;display:block}.recent-host-copy{gap:3px;min-width:0;display:grid}.recent-host-copy strong{font-size:11px}.file-connection-label span,.recent-host-copy small{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.recent-host-tag{background:var(--control);color:var(--muted);border-radius:5px;justify-self:start;padding:3px 7px;font-size:8px}.recent-host-status{justify-self:end;justify-items:end;gap:2px;display:grid}.recent-host-status b{align-items:center;gap:6px;font-size:9px;display:inline-flex}.recent-host-status b i{background:var(--faint);border-radius:50%;width:5px;height:5px}.recent-host-status small{color:var(--faint);font-size:8px;font-weight:500}.recent-host-status.ok b{color:var(--success)}.recent-host-status.ok b i{background:var(--success)}.recent-host-status.failed b{color:var(--danger)}.recent-host-status.failed b i{background:var(--danger)}.recent-host-status.stale b{color:var(--warning)}.recent-host-status.stale b i{background:var(--warning)}.recent-host-arrow{color:var(--muted);font-size:20px}.recent-hosts-more{width:100%;min-height:40px;color:var(--muted);background:0 0;font-size:9px;font-weight:700}.recent-hosts-more:hover{color:var(--accent)}.remote-browser{border:1px solid var(--line);background:var(--surface);border-radius:11px;flex-direction:column;height:100%;min-height:600px;display:flex;overflow:hidden}.remote-host-bar{border-bottom:1px solid var(--line);align-items:center;gap:12px;min-height:72px;padding:12px 16px;display:flex}.remote-host-icon{background:var(--accent-soft);width:39px;height:39px;color:var(--accent);border-radius:10px;flex:0 0 39px;place-items:center;display:grid}.remote-host-icon svg,.row-icon-button svg,.toolbar-button svg{fill:none;stroke:currentColor;stroke-width:1.7px;stroke-linecap:round;stroke-linejoin:round;width:21px;height:21px}.file-connection-label{flex:1;gap:3px;min-width:0;display:grid}.file-connection-label strong{align-items:baseline;gap:8px;font-size:13px;display:flex}.file-connection-label strong small{color:var(--faint);letter-spacing:.08em;text-transform:uppercase;font-size:8px}.compact-status{background:var(--control);color:var(--muted);border-radius:999px;align-items:center;gap:6px;padding:5px 8px;font-size:9px;font-weight:720;display:inline-flex}.compact-status i{background:currentColor;border-radius:50%;width:5px;height:5px}.compact-status.ok{background:var(--success-soft);color:var(--success)}.compact-status.failed{background:var(--danger-soft);color:var(--danger)}.compact-status.stale{background:var(--warning-soft);color:var(--warning)}.file-command-bar{border-bottom:1px solid var(--line);background:var(--surface-soft);grid-template-columns:auto minmax(120px,1fr) auto;align-items:center;gap:10px;min-height:48px;padding:7px 10px;display:grid}.file-command-actions,.file-nav-buttons{align-items:center;gap:4px;display:flex}.row-icon-button,.toolbar-button{color:var(--muted);background:0 0;border:1px solid #0000;place-items:center;display:inline-grid}.toolbar-button{border-radius:7px;min-width:32px;height:32px;padding:0 8px}.row-icon-button:hover,.toolbar-button:hover{border-color:var(--line);background:var(--control);color:var(--ink)}.row-icon-button svg,.toolbar-button svg{stroke-width:1.8px;width:16px;height:16px}.text-toolbar-button{gap:7px;width:auto;padding:0 9px;font-size:9px;font-weight:700;display:inline-flex}.file-breadcrumbs{scrollbar-width:none;white-space:nowrap;align-items:center;gap:3px;min-width:0;padding:0 5px;display:flex;overflow-x:auto}.file-breadcrumbs::-webkit-scrollbar{display:none}.breadcrumb-button{color:var(--muted);background:0 0;border-radius:5px;padding:4px 6px;font-size:10px}.breadcrumb-button:hover{background:var(--control);color:var(--accent)}.breadcrumb-current{color:var(--ink);padding:4px 6px;font-size:10px;font-weight:750}.breadcrumb-separator{color:var(--faint);font-size:9px}.file-path-form{grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:8px;min-height:46px;padding:6px 12px;display:grid}.file-path-form label{grid-template-columns:auto minmax(0,1fr);align-items:center;gap:9px;min-width:0;display:grid}.file-path-form label span{color:var(--faint);letter-spacing:.1em;text-transform:uppercase;font-size:8px;font-weight:800}.file-path-form input{background:var(--surface-soft);width:100%;min-width:0;height:31px;color:var(--ink);border:1px solid #0000;border-radius:6px;padding:5px 9px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:9px}.file-path-form .button{min-height:31px;padding:5px 12px;font-size:10px}.file-filter-bar,.file-path-form,.file-table th{border-bottom:1px solid var(--line)}.file-filter-bar{align-items:center;gap:14px;min-height:47px;padding:7px 12px;display:flex}.file-search{min-width:180px}.file-search,.surface-search{flex:1;position:relative}.file-search svg,.surface-search svg{fill:none;width:14px;height:14px;stroke:var(--faint);stroke-width:1.8px;stroke-linecap:round;position:absolute;top:9px;left:10px}.file-search input,.surface-search input{border:1px solid var(--line);background:var(--surface-soft);width:100%;height:33px;color:var(--ink);border-radius:7px;padding:6px 10px 6px 31px;font-size:10px}.show-hidden-toggle{color:var(--muted);white-space:nowrap;align-items:center;gap:7px;font-size:9px;display:flex}.show-hidden-toggle input{width:14px;height:14px;accent-color:var(--accent)}.file-list-status{color:var(--faint);white-space:nowrap;margin-left:auto;font-size:9px}.file-table-wrap{flex:1;min-height:0;overflow:auto}table{border-collapse:collapse;width:100%}.file-table{table-layout:fixed}.file-table th{z-index:2;background:var(--surface-soft);height:37px;color:var(--faint);text-align:left;letter-spacing:.08em;text-transform:uppercase;padding:0 10px;font-size:8px;font-weight:800;position:sticky;top:0}.file-table td:first-child,.file-table th:first-child{width:46%;padding-left:16px}.file-table td:nth-child(2),.file-table th:nth-child(2){width:10%}.file-table td:nth-child(3),.file-table th:nth-child(3){width:17%}.file-table td:nth-child(4),.file-table th:nth-child(4){width:23%}.file-table td:last-child,.file-table th:last-child{text-align:right;width:44px;padding-right:12px}.file-table td{border-bottom:1px solid var(--line);height:49px;padding:7px 10px;font-size:10px}.file-table tbody tr:nth-child(2n){background:color-mix(in srgb,var(--surface-soft) 60%,transparent)}.file-table tbody tr:hover,.management-table tbody tr:hover{background:var(--row-hover)}.sort-button{color:inherit;font:inherit;letter-spacing:inherit;text-transform:inherit;background:0 0;align-items:center;gap:5px;padding:4px 0;display:inline-flex}.sort-button.active,.sort-button:hover{color:var(--ink)}.sort-button span{color:var(--accent)}.file-name-cell{align-items:center;gap:10px;min-width:0;display:flex}.file-icon{background:var(--control);width:27px;height:27px;color:var(--muted);border-radius:7px;flex:0 0 27px;place-items:center;display:grid;position:relative}.file-icon.directory{background:var(--accent-soft);color:var(--accent)}.file-icon.symlink{background:var(--violet-soft);color:var(--violet)}.file-icon:before{content:"";border:1.5px solid;border-radius:2px;width:11px;height:14px}.file-icon.directory:before{width:15px;height:10px;margin-top:3px}.file-icon.directory:after{content:"";border:1.5px solid;border-bottom:0;border-radius:2px 2px 0 0;width:7px;height:4px;position:absolute;top:7px;left:6px}.file-icon.symlink:before{content:"↗";border:0;width:auto;height:auto;font-size:16px;font-weight:760}.file-name,.file-name-button{max-width:100%;color:var(--ink);text-align:left;text-overflow:ellipsis;white-space:nowrap;font-size:10px;font-weight:690;display:block;overflow:hidden}.file-name-button{background:0 0;padding:0}.file-name-button:hover{color:var(--accent);text-underline-offset:3px;text-decoration:underline}.file-kind{color:var(--faint);text-transform:capitalize;margin-top:2px;font-size:8px;display:block}.file-modified-column,.file-size,.permission-summary{color:var(--muted);white-space:nowrap}.permission-summary{gap:2px;display:grid}.permission-summary b{color:var(--ink);font-size:8px}.permission-summary small{color:var(--faint);font-size:8px}.row-icon-button{border-radius:7px;width:30px;height:30px;padding:0}.row-icon-button.danger,.row-menu button.danger{color:var(--danger)}.row-icon-button.danger:hover{border-color:var(--danger-border);background:var(--danger-soft)}.file-list-empty{min-height:220px;color:var(--muted);text-align:center;flex:1;place-content:center;gap:6px;display:grid}.file-list-empty strong{color:var(--ink);font-size:12px}.file-browser-error strong,.file-list-empty span{font-size:10px}.file-list-more{border-top:1px solid var(--line);background:var(--surface-soft);justify-content:center;align-items:center;gap:12px;min-height:46px;padding:7px 12px;display:flex}.file-list-more span,.management-table code{color:var(--muted);font-size:9px}.file-loading{gap:1px;padding:7px 0;display:grid}.file-loading span{background:var(--control);height:48px}.file-browser-error{border:1px solid var(--danger-border);background:var(--danger-soft);color:var(--danger);border-radius:9px;grid-template-columns:auto 1fr auto;align-items:center;gap:11px;margin:12px;padding:12px;display:grid}.error-icon{background:var(--danger-border);border-radius:50%;place-items:center;width:25px;height:25px;font-weight:850;display:grid}.file-browser-error div{gap:2px;display:grid}.file-browser-error div span{overflow-wrap:anywhere;font-size:9px}.management-view{gap:18px;display:grid}.connection-summary{grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;display:grid}.connection-summary article{border:1px solid var(--line);background:color-mix(in srgb,var(--surface) 94%,transparent);border-radius:10px;grid-template-columns:50px minmax(0,1fr);align-items:center;gap:16px;min-height:112px;padding:18px 22px;display:grid}.summary-icon{border:1px solid var(--accent-border);background:var(--accent-soft);width:50px;height:50px;color:var(--accent);border-radius:50%;place-items:center;display:grid}.summary-icon.reachable{border-color:var(--success-border);background:var(--success-soft);color:var(--success)}.summary-icon.credentials{border-color:var(--warning-border);background:var(--warning-soft);color:var(--warning)}.summary-icon svg{fill:none;stroke:currentColor;stroke-width:1.8px;stroke-linecap:round;stroke-linejoin:round;width:23px;height:23px}.connection-summary article div{gap:4px;min-width:0;display:grid}.connection-summary small{color:var(--muted);font-size:10px;font-weight:700}.connection-summary strong{font-size:24px;line-height:1}.connection-summary p,.surface-heading p{color:var(--muted);margin:0;font-size:9px}.connection-toolbar-mount{min-height:54px}.management-surface{border:1px solid var(--line);background:color-mix(in srgb,var(--surface) 95%,transparent);border-radius:10px;overflow:visible}.surface-heading{justify-content:space-between;align-items:center;gap:18px;min-height:62px;padding:13px 18px;display:flex}.surface-heading h2{letter-spacing:-.015em;margin:0;font-size:14px}.surface-heading h2 span{color:var(--muted);margin-left:7px;font-size:9px;font-weight:600}.surface-heading p{margin:3px 0 0}.connection-meta{align-items:center;gap:10px;min-width:0;display:flex}.table-wrap{overflow-x:auto}.management-table th{border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:var(--surface-soft);height:42px;color:var(--faint);text-align:left;letter-spacing:.09em;text-transform:uppercase;padding:8px 12px;font-size:8px;font-weight:820}.management-table td{border-bottom:1px solid var(--line);vertical-align:middle;height:70px;padding:10px 12px;font-size:10px}.management-table tbody tr:last-child td{border-bottom:0}.management-table td:last-child{text-align:right}.table-actions{white-space:nowrap;justify-content:flex-end;align-items:center;gap:6px;display:flex}.alias{font-weight:760}.subtle{color:var(--muted);font-size:8px}.connection-table{table-layout:fixed;min-width:1140px}.connection-table th:first-child{width:44px}.connection-table th:nth-child(2){width:15%}.connection-table th:nth-child(3){width:17%}.connection-table th:nth-child(4){width:18%}.connection-table th:nth-child(5),.connection-table th:nth-child(6),.connection-table th:nth-child(7),.connection-table th:nth-child(8){width:13%}.connection-name{grid-template-columns:7px minmax(0,1fr);align-items:center;gap:10px;min-width:125px;display:grid}.connection-name>div{gap:5px;min-width:0;display:grid}.connection-name .subtle,.endpoint-cell code{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.endpoint-cell,.route-cell{gap:6px;min-width:0;display:grid}.endpoint-cell>span,.tag-list{align-items:center;display:flex}.endpoint-cell>span{color:var(--faint);gap:7px;font-size:8px}.endpoint-cell>span b{background:var(--control);color:var(--muted);border-radius:4px;padding:2px 5px;font-size:7px}.tag-list{flex-wrap:wrap;gap:4px;max-width:220px}.table-context-tag{padding:3px 7px;font-size:8px}.table-context-edit,.table-context-empty,.table-context-tag{border:1px solid var(--accent-border);min-height:23px;color:var(--accent);background:0 0;border-radius:999px;font-weight:700}.table-context-empty{padding:3px 7px;font-size:8px}.table-context-edit:hover,.table-context-empty:hover,.table-context-tag:hover{background:var(--accent-soft)}.table-context-edit{border-style:dashed;width:23px;padding:0;font-size:13px}.table-context-empty{border-style:dashed}.credential-cell{align-items:center;gap:8px;min-width:0;display:flex}.credential-cell>i{background:var(--success-soft);width:16px;height:16px;color:var(--success);border-radius:50%;place-items:center;font-size:9px;font-style:normal;display:grid}.credential-cell span{gap:4px;min-width:0;display:grid}.credential-cell small,.credential-cell strong,.route-cell small,.route-cell strong{text-overflow:ellipsis;white-space:nowrap;font-size:9px;overflow:hidden}.credential-cell small,.route-cell small{color:var(--muted);text-transform:capitalize;font-size:8px}.status-block{justify-items:start;gap:4px;display:grid}.status-block>small{color:var(--faint);padding-left:9px;font-size:8px}.status-pill{background:var(--control);min-height:24px;color:var(--muted);white-space:nowrap;border-radius:999px;align-items:center;gap:6px;padding:4px 8px;font-size:8px;font-weight:750;display:inline-flex}.credential-ready i,.status-pill i{background:currentColor;border-radius:50%;width:5px;height:5px}.status-pill.ok{background:var(--success-soft);color:var(--success)}.status-pill.failed{background:var(--danger-soft);color:var(--danger)}.status-pill.stale{background:var(--warning-soft);color:var(--warning)}.connection-actions,.row-menu{position:relative}.connection-actions .browse-files{background:var(--accent-soft);color:var(--accent)}.row-menu summary{border:1px solid var(--line);width:34px;height:34px;color:var(--muted);cursor:pointer;background:0 0;border-radius:7px;place-items:center;font-size:10px;list-style:none;display:grid}.row-menu summary::-webkit-details-marker{display:none}.row-menu>div{z-index:10;border:1px solid var(--line-strong);background:var(--surface);width:150px;box-shadow:var(--shadow-dialog);border-radius:8px;gap:2px;padding:5px;display:grid;position:absolute;top:calc(100% + 5px);right:0}.row-menu button{min-height:31px;color:var(--ink);text-align:left;background:0 0;border-radius:5px;padding:6px 8px;font-size:9px}.management-surface>.empty{color:var(--muted);text-align:center;margin:0;padding:44px 20px;font-size:10px}.security-note{border:1px solid var(--line);background:color-mix(in srgb,var(--surface) 95%,transparent);border-radius:10px;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:13px;min-height:64px;padding:13px 17px;display:grid}.security-note>span{background:var(--accent-soft);width:32px;height:32px;color:var(--accent);border-radius:7px;place-items:center;display:grid}.security-note svg{fill:none;stroke:currentColor;stroke-width:1.8px;stroke-linecap:round;stroke-linejoin:round;width:18px;height:18px}.security-note strong{margin-bottom:3px;font-size:11px;display:block}.reauth-card p,.security-note p{color:var(--muted);margin:0;font-size:9px}.credential-heading{min-height:78px}.credential-tools{align-items:center;gap:9px;min-width:min(440px,45vw);display:flex}.surface-search{min-width:220px}.credential-tools select{min-width:120px}.credential-table{table-layout:fixed;min-width:990px}.credential-table th:first-child{width:14%}.credential-table th:nth-child(2),.credential-table th:nth-child(3){width:10%}.credential-table th:nth-child(4){width:16%}.credential-table th:nth-child(5){width:24%}.credential-table th:nth-child(6){width:12%}.credential-table th:nth-child(7){width:14%}.credential-kind{color:var(--muted);text-transform:capitalize}.credential-ready{background:var(--control);color:var(--muted);border-radius:999px;align-items:center;gap:6px;padding:4px 7px;font-size:8px;font-weight:720;display:inline-flex}.credential-ready.ready,.reauth-status>span{background:var(--success-soft);color:var(--success)}.credential-ready.incomplete{background:var(--danger-soft);color:var(--danger)}.key-location{align-items:center;gap:5px;min-width:0;display:flex}.key-location code{text-overflow:ellipsis;white-space:nowrap;max-width:250px;overflow:hidden}.endpoint-copy{min-height:24px;color:var(--muted);background:0 0;border-radius:5px;padding:3px 6px;font-size:8px;font-weight:700}.endpoint-copy:hover{background:var(--control);color:var(--ink)}.last-checked,.reauth-status small{color:var(--muted);font-size:9px}.reauth-card,dialog{border:1px solid var(--line)}.reauth-card{background:color-mix(in srgb,var(--surface) 95%,transparent);border-radius:10px;overflow:hidden}.reauth-card>header{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;gap:18px;min-height:74px;padding:14px 18px;display:flex}.reauth-card h2{margin-bottom:5px;font-size:14px}.reauth-status{align-items:center;gap:12px;min-height:64px;padding:13px 20px;display:flex}.reauth-status>span{border-radius:50%;place-items:center;width:24px;height:24px;font-size:11px;display:grid}.reauth-status div{gap:4px;display:grid}.reauth-status strong{font-size:10px}dialog{background:var(--surface);width:min(560px,100% - 28px);max-height:min(88vh,780px);color:var(--ink);box-shadow:var(--shadow-dialog);border-radius:14px;padding:0;overflow-y:auto}dialog::backdrop{background:var(--overlay)}dialog form{margin:0}.dialog-heading{justify-content:space-between;align-items:center;gap:16px;margin-bottom:20px;display:flex}.dialog-heading h2{margin:0;font-size:18px}.icon-button{color:var(--muted);background:0 0;border-radius:7px;padding:3px 8px;font-size:24px}.icon-button:hover{background:var(--control)}dialog label{gap:7px;margin-bottom:15px;font-size:11px;font-weight:720;display:grid}dialog input,dialog select,dialog textarea{border:1px solid var(--line-strong);background:var(--surface);width:100%;color:var(--ink);border-radius:8px;padding:9px 10px;font-size:12px}.two-columns{grid-template-columns:2fr 1fr;gap:12px;display:grid}.checkbox-label,.credential-picker{align-items:center;gap:8px;display:flex}.credential-picker select{flex:1;min-width:0}.checkbox-label{gap:9px}.checkbox-label input{width:auto}.inline-composer{border:1px solid var(--accent-border);background:var(--accent-soft);border-radius:10px;margin:2px 0 16px;padding:15px 16px 3px}.inline-composer legend{color:var(--accent);padding:0 6px;font-size:11px;font-weight:800}.inline-composer .hint{margin:-2px 0 14px}.inline-composer label{margin-bottom:12px}.dialog-actions,.inline-composer-actions{justify-content:flex-end;gap:8px;display:flex}.inline-composer-actions{margin:4px 0 12px}.dialog-actions{margin-top:22px}.hint{color:var(--muted);font-size:10px}#revealDialog,dialog form{padding:24px}#revealDialog p,#toast{color:var(--muted);font-size:11px}.secret-box{background:var(--sidebar);color:var(--sidebar-text);border-radius:9px;justify-content:space-between;align-items:center;gap:12px;padding:13px;display:flex;overflow:hidden}.secret-box code{white-space:pre;overflow-x:auto}#toast{z-index:100;background:var(--ink);max-width:min(430px,100% - 40px);color:var(--surface);opacity:0;pointer-events:none;border-radius:9px;padding:11px 14px;transition:all .18s;position:fixed;bottom:20px;right:20px;transform:translateY(10px);box-shadow:0 14px 40px #0000003d}#toast.show{opacity:1;transform:none}#toast.error{background:var(--danger);color:#fff}@media (width<=1180px){.app-shell{grid-template-columns:260px minmax(0,1fr)}.connection-summary{grid-template-columns:repeat(2,minmax(0,1fr))}.connection-summary article{min-height:96px}.view-header{padding-inline:24px}.view-stage{padding-inline:22px}.workspace-start-actions{gap:10px}.start-card{padding:13px}}@media (width<=820px){body{overflow:auto}.app-shell{height:auto;min-height:100dvh;display:block}.sidebar{grid-template-columns:auto 1fr;align-items:center;gap:15px;height:auto;min-height:0;padding:11px 15px;display:grid;overflow:visible}.brand{padding:0}.brand span:not(.brand-mark){display:none}.brand strong{font-size:13px}.brand-mark{flex-basis:34px;width:34px;height:34px}.primary-nav{border:0;justify-content:flex-end;gap:4px;padding:0;display:flex}.nav-item{grid-template-columns:18px auto auto;width:auto;min-height:36px;padding:7px 8px}.host-navigation,.label-navigation,.sidebar-footer{display:none!important}.app-content{height:auto;min-height:calc(100dvh - 58px)}.view-header{min-height:90px;padding:16px 18px 10px}.view-stage{min-height:680px;padding:10px 15px 18px}.workspace-view{height:calc(100dvh - 175px);min-height:620px}.mobile-host-controls{margin-bottom:10px;display:block}.mobile-host-controls label{color:var(--muted);text-transform:uppercase;gap:4px;font-size:9px;font-weight:750;display:grid}.mobile-host-controls select{border:1px solid var(--line-strong);background:var(--surface);height:36px;color:var(--ink);text-transform:none;border-radius:8px;padding:7px 9px;font-size:11px}.workspace-zero-state{min-height:610px;padding:34px 24px}.workspace-start-actions{grid-template-columns:1fr;margin-top:28px}.start-card{min-height:76px}.remote-browser{height:calc(100% - 58px)}.credential-tools{min-width:0}}@media (width<=640px){.sidebar{padding:9px 10px;display:block}.brand{display:none}.primary-nav{justify-content:stretch}.nav-item{flex:1;grid-template-columns:17px minmax(0,auto);justify-content:center;min-width:0;font-size:10px}.nav-count{display:none}.view-header{flex-direction:column;align-items:flex-start;gap:12px}.view-heading h1{font-size:22px}.view-heading>span{line-height:1.45}.header-action-group,.view-actions{width:100%}.context-control{flex:1;min-width:0}.context-control select{width:100%;min-width:0}.theme-control{margin-left:auto}.header-action-group .button{flex:1}.view-stage{min-height:650px;padding:8px}.workspace-view{height:auto;min-height:580px}.workspace-zero-state{min-height:600px;padding:28px 13px}.empty-terminal{width:88px;height:78px;margin-bottom:20px}.recent-host-row{grid-template-columns:30px minmax(0,1fr) auto 12px;padding-inline:12px}.recent-host-status small,.recent-host-tag{display:none}.connection-summary{grid-template-columns:1fr 1fr;gap:8px}.connection-summary article{grid-template-columns:38px minmax(0,1fr);gap:10px;min-height:90px;padding:12px}.summary-icon{width:38px;height:38px}.connection-summary strong{font-size:19px}.credential-heading,.credential-tools,.reauth-card>header,.surface-heading{flex-direction:column;align-items:stretch}.credential-tools{width:100%}.surface-search{min-width:0}.remote-browser{min-height:540px}.compact-status,.text-toolbar-button span{display:none}.file-command-bar{grid-template-columns:auto minmax(80px,1fr) auto;gap:4px;padding:6px}.file-path-form{padding:6px 8px}.file-modified-column,.file-path-form label span,.file-permissions-column{display:none}.file-table td:first-child,.file-table th:first-child{width:72%}.file-table td:nth-child(2),.file-table th:nth-child(2){width:18%}.file-filter-bar{flex-wrap:wrap;gap:7px}.file-search{min-width:70%;max-width:none}.file-list-status{white-space:normal;width:100%;margin-left:0}.two-columns{grid-template-columns:1fr;gap:0}}@media (prefers-reduced-motion:reduce){*,:after,:before{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}
@@ -45,7 +45,8 @@ def format_item(item: Any) -> str:
45
45
  if "hostname" in item:
46
46
  credential = item.get("credential_label") or "OpenSSH default"
47
47
  jumps = ",".join(item.get("proxy_jumps", [])) or "-"
48
- return f"{item['alias']:<22} {item['username']}@{item['hostname']}:{item['port']} auth={credential} jump={jumps}"
48
+ tags = ",".join(item.get("tags", [])) or "-"
49
+ return f"{item['alias']:<22} {item['username']}@{item['hostname']}:{item['port']} auth={credential} jump={jumps} tags={tags}"
49
50
  if "kind" in item:
50
51
  detail = item.get("key_path") or ("stored" if item.get("has_secret") else "not stored")
51
52
  return f"{item['label']:<22} {item['kind']:<8} {detail}"
@@ -77,6 +78,10 @@ def build_parser() -> argparse.ArgumentParser:
77
78
  type=Path,
78
79
  help="write the one-time URL to a mode-600 local file (required with --no-open)",
79
80
  )
81
+ ui_action = ui.add_mutually_exclusive_group()
82
+ ui_action.add_argument("--status", action="store_true", help="report the managed UI process instead of launching")
83
+ ui_action.add_argument("--stop", action="store_true", help="stop the managed UI process and clean up its URL file")
84
+ add_json_option(ui)
80
85
 
81
86
  server = commands.add_parser("server", help="manage connection profiles")
82
87
  server_commands = server.add_subparsers(dest="server_command", required=True)
@@ -93,6 +98,7 @@ def build_parser() -> argparse.ArgumentParser:
93
98
  server_add.add_argument("--port", type=int, default=22)
94
99
  server_add.add_argument("--credential")
95
100
  server_add.add_argument("--proxy-jump", action="append", default=[])
101
+ server_add.add_argument("--tag", action="append", default=[], help="project/context tag; repeat as needed")
96
102
  server_add.add_argument("--notes", default="")
97
103
  add_json_option(server_add)
98
104
 
@@ -104,6 +110,9 @@ def build_parser() -> argparse.ArgumentParser:
104
110
  server_edit.add_argument("--port", type=int)
105
111
  server_edit.add_argument("--credential", help="credential label/id, or 'none'")
106
112
  server_edit.add_argument("--proxy-jump", action="append")
113
+ edit_tags = server_edit.add_mutually_exclusive_group()
114
+ edit_tags.add_argument("--tag", action="append", help="replace project/context tags; repeat as needed")
115
+ edit_tags.add_argument("--clear-tags", action="store_true")
107
116
  server_edit.add_argument("--notes")
108
117
  add_json_option(server_edit)
109
118
 
@@ -228,6 +237,7 @@ def run_doctor(database: Database) -> dict[str, Any]:
228
237
  },
229
238
  "database": {"ok": True, "path": str(database_path())},
230
239
  "ssh": {"ok": bool(ssh_path), "path": ssh_path, "client": ssh_client_version(ssh_path)},
240
+ "sftp": {"ok": bool(shutil.which("sftp")), "path": shutil.which("sftp")},
231
241
  "ssh_keygen": {"ok": bool(shutil.which("ssh-keygen")), "path": shutil.which("ssh-keygen")},
232
242
  "original_config": {"ok": True, "path": str(original_ssh_config_path())},
233
243
  "managed_config": {"ok": True, "path": str(managed_ssh_config_path())},
@@ -253,8 +263,16 @@ def handle(args: argparse.Namespace) -> int:
253
263
  emit(result, as_json=args.json)
254
264
  return 0 if result["ok"] else 1
255
265
  if args.command == "ui":
256
- from .webapp import run_ui
266
+ from .webapp import run_ui, ui_status, ui_stop
257
267
 
268
+ if args.status:
269
+ result = ui_status()
270
+ emit(result, as_json=args.json)
271
+ return 0 if result["running"] else 1
272
+ if args.stop:
273
+ result = ui_stop()
274
+ emit(result, as_json=args.json)
275
+ return 0 if not result["running"] else 1
258
276
  run_ui(
259
277
  database=database,
260
278
  port=args.port,
@@ -275,6 +293,7 @@ def handle(args: argparse.Namespace) -> int:
275
293
  username=args.username,
276
294
  credential_id=resolve_credential(database, args.credential),
277
295
  proxy_jumps=args.proxy_jump,
296
+ tags=args.tag,
278
297
  notes=args.notes,
279
298
  )
280
299
  render_config(database)
@@ -295,6 +314,10 @@ def handle(args: argparse.Namespace) -> int:
295
314
  changes["credential_id"] = resolve_credential(database, args.credential)
296
315
  if args.proxy_jump is not None:
297
316
  changes["proxy_jumps"] = args.proxy_jump
317
+ if args.clear_tags:
318
+ changes["tags"] = []
319
+ elif args.tag is not None:
320
+ changes["tags"] = args.tag
298
321
  result = database.update_server(args.alias, **changes)
299
322
  render_config(database)
300
323
  emit(result, as_json=args.json)
@@ -350,6 +373,12 @@ def handle(args: argparse.Namespace) -> int:
350
373
  emit(result, as_json=args.json)
351
374
  return 0
352
375
  if args.command == "connect":
376
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
377
+ raise SSHError(
378
+ "connect opens an interactive shell and requires a terminal (TTY); "
379
+ "run this command directly in your own terminal, or use "
380
+ "'serverctl exec ALIAS -- COMMAND' to run commands non-interactively"
381
+ )
353
382
  return SSHRunner(database).connect(args.alias, timeout=args.timeout)
354
383
  if args.command == "exec":
355
384
  if args.reuse > 0 and os.name == "nt":