The palette now follows Oxide's design system (docs.oxide.computer): a cool neutral scale at hue 260 with almost no chroma, opaque strokes, and Oxide's green as the only accent. The accent-color option is gone: selection, the cursor row, focus rings and the active-panel line all read green. The file panel's header strip now steps toward the list, one shade per row (session strip, path bar, column headers), derived by color-mix so every theme, AMOLED included, follows on its own. Everything is square: every rounded utility and the radius tokens are gone, and checkboxes, radios, selects, number spinners and scrollbars are restyled to match (square scrollbars are always visible on macOS, the cost of styling them). Co-Authored-By: Claude <noreply@anthropic.com>
382 lines
18 KiB
HTML
382 lines
18 KiB
HTML
<div class="flex h-screen flex-col bg-background text-foreground">
|
|
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
|
|
elsewhere the native titlebar sits above and this is a compact strip -->
|
|
<header
|
|
class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
|
|
[class.h-10]="isMacChrome"
|
|
[class.pl-24]="isMacChrome"
|
|
[class.electrobun-webkit-app-region-drag]="isMacChrome"
|
|
[class.h-9]="!isMacChrome"
|
|
[class.pl-3]="!isMacChrome"
|
|
>
|
|
<ng-icon name="tdesignServer" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
|
<h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1>
|
|
<div class="flex-1"></div>
|
|
<button
|
|
(click)="importWinScp()"
|
|
[disabled]="importing()"
|
|
[title]="i18n.t('connMgr.importWinscpHint')"
|
|
class="flex items-center gap-1.5 px-2 py-1 text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50"
|
|
>
|
|
@if (importing()) {
|
|
<ng-icon name="tdesignLoading" size="0.85rem" class="animate-spin" />
|
|
} @else {
|
|
<!-- <ng-icon name="tdesignFileZip" size="0.85rem" /> -->
|
|
}
|
|
{{ i18n.t('connMgr.importWinscp') }}
|
|
</button>
|
|
</header>
|
|
|
|
<div class="flex min-h-0 flex-1">
|
|
<!-- stacks + connections list -->
|
|
<div class="flex w-72 shrink-0 flex-col border-r border-border bg-chrome">
|
|
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
|
<button
|
|
(click)="newConnection()"
|
|
[title]="i18n.t('connMgr.newConnectionHint')"
|
|
class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<ng-icon name="tdesignAdd" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }}
|
|
</button>
|
|
<button
|
|
(click)="newStack()"
|
|
[title]="i18n.t('connMgr.newStackHint')"
|
|
class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<ng-icon name="tdesignFolderAdd" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto py-1">
|
|
<!-- stacks -->
|
|
@for (group of store.grouped().stacks; track group.stack.id) {
|
|
<div
|
|
(dragover)="onDragOver($event, group.stack.id)"
|
|
(dragleave)="dropTarget() === group.stack.id && dropTarget.set(null)"
|
|
(drop)="onDrop($event, group.stack.id)"
|
|
[class.bg-primary/10]="dropTarget() === group.stack.id"
|
|
[class.ring-1]="dropTarget() === group.stack.id"
|
|
[class.ring-inset]="dropTarget() === group.stack.id"
|
|
[class.ring-primary]="dropTarget() === group.stack.id"
|
|
>
|
|
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
|
|
<button
|
|
(click)="toggleStack(group.stack)"
|
|
class="grid size-4 shrink-0 place-items-center text-muted-foreground hover:text-foreground"
|
|
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
|
|
>
|
|
<ng-icon
|
|
[name]="group.stack.collapsed ? 'tdesignChevronRight' : 'tdesignChevronDown'"
|
|
size="0.8rem"
|
|
/>
|
|
</button>
|
|
<span class="truncate text-[0.6875rem] font-medium text-muted-foreground uppercase">
|
|
{{ group.stack.name }}
|
|
</span>
|
|
<div class="min-w-0 flex-1"></div>
|
|
<!-- count and actions swap on hover: they never share the space,
|
|
so a long stack name can't collide with the buttons -->
|
|
<span
|
|
class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-hover:hidden"
|
|
>
|
|
{{ group.connections.length }}
|
|
</span>
|
|
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
|
<button
|
|
(click)="renameStack(group.stack)"
|
|
[title]="i18n.t('connMgr.renameStack')"
|
|
class="grid size-4 place-items-center text-muted-foreground hover:text-foreground"
|
|
>
|
|
<ng-icon name="tdesignEdit" size="0.75rem" />
|
|
</button>
|
|
<button
|
|
(click)="deleteStack(group.stack)"
|
|
[title]="i18n.t('connMgr.deleteStack')"
|
|
class="grid size-4 place-items-center text-muted-foreground hover:text-destructive"
|
|
>
|
|
<ng-icon name="tdesignDelete" size="0.75rem" />
|
|
</button>
|
|
@if (group.connections.length) {
|
|
<button
|
|
(click)="openStack(group.stack.id)"
|
|
[title]="i18n.t('connMgr.openStack')"
|
|
class="grid size-4 place-items-center text-muted-foreground hover:text-primary"
|
|
>
|
|
<ng-icon name="tdesignArrowRight" size="0.8rem" />
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
@if (!group.stack.collapsed) {
|
|
@for (connection of group.connections; track connection.id) {
|
|
<button
|
|
(click)="edit(connection)"
|
|
draggable="true"
|
|
(dragstart)="onDragStart($event, connection)"
|
|
(dragend)="onDragEnd()"
|
|
class="flex w-full items-center gap-1.5 py-1.5 pr-3 pl-6 text-left text-xs hover:bg-accent"
|
|
[class.bg-accent]="draft()?.id === connection.id"
|
|
[class.opacity-50]="draggedId() === connection.id"
|
|
>
|
|
<ng-icon name="tdesignServer" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
|
<span class="truncate">{{ connection.name }}</span>
|
|
</button>
|
|
} @empty {
|
|
<div class="py-1 pl-6 text-[0.6875rem] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div>
|
|
}
|
|
}
|
|
</div>
|
|
}
|
|
|
|
<!-- loose connections -->
|
|
<div
|
|
(dragover)="onDragOver($event, 'loose')"
|
|
(dragleave)="dropTarget() === 'loose' && dropTarget.set(null)"
|
|
(drop)="onDrop($event, null)"
|
|
class="min-h-8 pb-1"
|
|
[class.bg-primary/10]="dropTarget() === 'loose'"
|
|
[class.ring-1]="dropTarget() === 'loose'"
|
|
[class.ring-inset]="dropTarget() === 'loose'"
|
|
[class.ring-primary]="dropTarget() === 'loose'"
|
|
>
|
|
@if (store.grouped().stacks.length && store.grouped().loose.length) {
|
|
<div class="px-2 pt-2 pb-0.5 text-[0.6875rem] font-medium text-muted-foreground uppercase">
|
|
{{ i18n.t('connMgr.connectionsHeader') }}
|
|
</div>
|
|
}
|
|
@for (connection of store.grouped().loose; track connection.id) {
|
|
<button
|
|
(click)="edit(connection)"
|
|
draggable="true"
|
|
(dragstart)="onDragStart($event, connection)"
|
|
(dragend)="onDragEnd()"
|
|
class="flex w-full items-center gap-1.5 px-3 py-1.5 text-left text-xs hover:bg-accent"
|
|
[class.bg-accent]="draft()?.id === connection.id"
|
|
[class.opacity-50]="draggedId() === connection.id"
|
|
>
|
|
<ng-icon name="tdesignServer" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
|
<span class="truncate">{{ connection.name }}</span>
|
|
</button>
|
|
}
|
|
</div>
|
|
|
|
@if (!store.connections().length && !store.stacks().length) {
|
|
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
|
|
{{ i18n.t('connMgr.noneTitle') }}
|
|
</p>
|
|
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
|
|
{{ i18n.t('connMgr.noneBody') }}
|
|
</p>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- edit form -->
|
|
<div class="flex min-w-0 flex-1 flex-col">
|
|
@if (draft(); as d) {
|
|
<div class="grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5">
|
|
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
|
|
<input
|
|
[value]="d.name"
|
|
(input)="patch('name', $any($event.target).value)"
|
|
[placeholder]="i18n.t('connMgr.namePlaceholder')"
|
|
spellcheck="false"
|
|
class="h-7 border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
</label>
|
|
|
|
<label class="flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.host') }}</span>
|
|
<input
|
|
[value]="d.host"
|
|
(input)="patch('host', $any($event.target).value)"
|
|
placeholder="192.168.1.10"
|
|
spellcheck="false"
|
|
class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
</label>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<label class="flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.port') }}</span>
|
|
<input
|
|
type="number"
|
|
[value]="d.port"
|
|
(input)="patch('port', +$any($event.target).value)"
|
|
class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
</label>
|
|
<label class="flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.stack') }}</span>
|
|
<select
|
|
(change)="patch('stackId', $any($event.target).value || null)"
|
|
class="h-7 border border-input bg-background pl-1 outline-none focus:ring-2 focus:ring-ring"
|
|
>
|
|
<!-- [selected] per option: binding [value] on the <select> runs
|
|
before the options render and silently shows "(none)" -->
|
|
<option value="" [selected]="!d.stackId">{{ i18n.t('connMgr.stackNone') }}</option>
|
|
@for (stack of store.stacks(); track stack.id) {
|
|
<option [value]="stack.id" [selected]="d.stackId === stack.id">
|
|
{{ stack.name }}
|
|
</option>
|
|
}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<label class="flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.username') }}</span>
|
|
<input
|
|
[value]="d.username"
|
|
(input)="patch('username', $any($event.target).value)"
|
|
spellcheck="false"
|
|
class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
</label>
|
|
<label class="flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.remotePath') }}</span>
|
|
<input
|
|
[value]="d.initialPath"
|
|
(input)="patch('initialPath', $any($event.target).value)"
|
|
[placeholder]="i18n.t('connMgr.remotePathPlaceholder')"
|
|
spellcheck="false"
|
|
class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
</label>
|
|
|
|
<div class="col-span-2 flex items-center gap-4 text-xs">
|
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.auth') }}</span>
|
|
<label class="flex items-center gap-1.5">
|
|
<input
|
|
type="radio"
|
|
name="authKind"
|
|
[checked]="d.authKind === 'password'"
|
|
(change)="patch('authKind', 'password')"
|
|
/>
|
|
{{ i18n.t('connMgr.authPassword') }}
|
|
</label>
|
|
<label class="flex items-center gap-1.5">
|
|
<input
|
|
type="radio"
|
|
name="authKind"
|
|
[checked]="d.authKind === 'key'"
|
|
(change)="patch('authKind', 'key')"
|
|
/>
|
|
{{ i18n.t('connMgr.authKey') }}
|
|
</label>
|
|
</div>
|
|
|
|
@if (d.authKind === 'password') {
|
|
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.password') }}</span>
|
|
<input
|
|
type="password"
|
|
[value]="d.password"
|
|
(input)="patch('password', $any($event.target).value)"
|
|
class="h-7 border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
</label>
|
|
} @else {
|
|
<label class="flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.privateKeyFile') }}</span>
|
|
<div class="flex items-center gap-1">
|
|
<input
|
|
[value]="d.privateKeyPath"
|
|
(input)="patch('privateKeyPath', $any($event.target).value)"
|
|
placeholder="~/.ssh/id_ed25519"
|
|
spellcheck="false"
|
|
class="h-7 min-w-0 flex-1 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
<button
|
|
type="button"
|
|
(click)="pickKeyFile()"
|
|
class="h-7 shrink-0 border border-border px-2 text-[0.6875rem] hover:bg-accent"
|
|
>
|
|
{{ i18n.t('common.choose') }}
|
|
</button>
|
|
</div>
|
|
</label>
|
|
<label class="flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.passphrase') }}</span>
|
|
<input
|
|
type="password"
|
|
[value]="d.passphrase"
|
|
(input)="patch('passphrase', $any($event.target).value)"
|
|
class="h-7 border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
</label>
|
|
}
|
|
|
|
@if (moreOpen()) {
|
|
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.localDir') }}</span>
|
|
<input
|
|
[value]="d.localDir"
|
|
(input)="patch('localDir', $any($event.target).value)"
|
|
[placeholder]="i18n.t('connMgr.localDirPlaceholder')"
|
|
spellcheck="false"
|
|
class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
<span class="text-[0.625rem] text-muted-foreground/80">
|
|
{{ i18n.t('connMgr.localDirHint') }}
|
|
</span>
|
|
</label>
|
|
}
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5">
|
|
<button
|
|
(click)="deleteCurrent()"
|
|
class="flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
|
|
>
|
|
<ng-icon name="tdesignDelete" size="0.85rem" /> {{ i18n.t('common.delete') }}
|
|
</button>
|
|
<button
|
|
(click)="moreOpen.set(!moreOpen())"
|
|
class="border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
{{ moreOpen() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }}
|
|
</button>
|
|
<div class="flex-1"></div>
|
|
<button
|
|
(click)="save()"
|
|
[disabled]="!valid()"
|
|
class="px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
|
|
>
|
|
{{ i18n.t('common.save') }}
|
|
</button>
|
|
<button
|
|
(click)="saveAndConnect()"
|
|
[disabled]="!valid() || busy()"
|
|
class="flex items-center gap-1.5 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
>
|
|
@if (busy()) {
|
|
<ng-icon name="tdesignLoading" size="0.85rem" class="animate-spin" />
|
|
}
|
|
{{ i18n.t('common.connect') }}
|
|
</button>
|
|
</div>
|
|
} @else {
|
|
<div class="grid flex-1 place-items-center">
|
|
<div class="text-center">
|
|
<ng-icon name="tdesignServer" size="1.75rem" class="text-muted-foreground" />
|
|
<p class="mt-2 text-xs text-muted-foreground">
|
|
{{ i18n.t('connMgr.selectPrompt') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- in-window dialogs (safe here: they depend only on FocusService) -->
|
|
@if (dialogs.prompt(); as req) {
|
|
<app-prompt-dialog [request]="req" />
|
|
}
|
|
@if (dialogs.confirm(); as req) {
|
|
<app-confirm-dialog [request]="req" />
|
|
}
|
|
|
|
<app-toasts />
|
|
</div>
|