27 Commits

Author SHA1 Message Date
jamie da996705c9 Merge pull request 'feat: added api key support' (#13) from v1.1.0 into main
Reviewed-on: http://git.jdbnet.co.uk/jamie/ssh/pulls/13
2026-05-23 16:42:43 +01:00
jamie 6d341309f1 docs: 📝 remove unrequired variable from example
Release / release (pull_request) Successful in 24s
2026-05-23 15:42:22 +00:00
jamie 187a3c7882 fix: 🐛 client timeouts 2026-05-23 15:40:13 +00:00
jamie c7ffdf81c2 feat: add tags 2026-05-23 15:36:46 +00:00
jamie 0664d8763d feat: added api key support 2026-05-23 15:24:35 +00:00
jamie 853e06456e test: remove dummy project 2026-05-15 07:05:02 +00:00
jamie cae073728a Merge pull request 'fix: 🐛 branch name' (#11) from v1.0.0 into main
Reviewed-on: #11
2026-05-15 08:02:01 +01:00
jamie 0f35d5bd6f fix: 🐛 branch name
Release / release (pull_request) Successful in 26s
2026-05-15 07:01:38 +00:00
jamie 1d6cce88a8 Merge pull request 'feat: first release' (#10) from v1.0.0 into main
Reviewed-on: #10
2026-05-15 08:00:58 +01:00
jamie 18e256baee feat: first release 2026-05-15 07:00:35 +00:00
jamie 7682a94981 fix: 🐛 one-time credentials
CI / Build and Push (push) Successful in 4s
CI / SonarQube (push) Successful in 31s
2026-05-14 14:54:43 +00:00
jamie bb724377fe style: 🎨 more icons and ability to edit folders
CI / Build and Push (push) Successful in 8s
CI / SonarQube (push) Successful in 31s
2026-05-14 12:44:41 +00:00
jamie 22a3dc7cbe style: 🎨 delete icon and reduce icon size
CI / Build and Push (push) Successful in 7s
CI / SonarQube (push) Successful in 31s
2026-05-14 12:38:36 +00:00
jamie 6069f5395a style: 🎨 edit icon
CI / Build and Push (push) Successful in 8s
CI / SonarQube (push) Successful in 31s
2026-05-14 12:34:04 +00:00
jamie d542264567 feat: connection audit shows last 7 days
CI / Build and Push (push) Successful in 26s
CI / SonarQube (push) Successful in 31s
2026-05-14 12:08:06 +00:00
jamie 7f717684eb feat: sort hosts by alphabetically or last connected 2026-05-14 12:03:20 +00:00
jamie 90103f79c8 fix: 🐛 client sends ping to keep websocket connection alive 2026-05-14 11:56:33 +00:00
jamie 20db4742a7 feat: one time credentials for hosts 2026-05-14 11:53:41 +00:00
jamie ca8b5dea7f ci: 🚀 dev build 2026-05-14 11:47:22 +00:00
jamie ca3d27e7f9 fix: 🐛 don't close modals if you click outside 2026-05-14 11:43:55 +00:00
jamie 035f871b00 feat: edit identities 2026-05-14 11:35:59 +00:00
jamie 5bba2947c4 refactor: 🎨 when deleting an identity it tells you if it fails 2026-05-14 11:33:46 +00:00
jamie 4e77b8f412 docs: 📝 add screenshot to readme
CI / Build and Push (push) Successful in 5s
CI / SonarQube (push) Successful in 31s
2026-04-30 10:18:46 +01:00
jamie 28a121d7f4 ci: 🚀 add sonarqube
CI / Build and Push (push) Successful in 18s
CI / SonarQube (push) Successful in 32s
2026-04-29 18:06:43 +01:00
jamie aa83021393 style: 🎨 lucide icons
CI / Build and Push (push) Successful in 13s
2026-04-24 00:42:18 +01:00
jamie 99ce83c2df refactor: 🎨 change label on host form 2026-04-24 00:34:03 +01:00
jamie 5f00d40e66 fix: 🎨 app icon url 2026-04-24 00:33:44 +01:00
19 changed files with 2095 additions and 194 deletions
+5
View File
@@ -1,5 +1,10 @@
FROM mcr.microsoft.com/devcontainers/python:3.14 FROM mcr.microsoft.com/devcontainers/python:3.14
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workspace WORKDIR /workspace
CMD ["sleep", "infinity"] CMD ["sleep", "infinity"]
+2 -1
View File
@@ -3,4 +3,5 @@
__pycache__ __pycache__
*.pyc *.pyc
.env .env
frontend/node_modules frontend/node_modules
img/
+4 -1
View File
@@ -26,4 +26,7 @@ MYSQL_POOL_SIZE=5
MAX_CONCURRENT_SSH=32 MAX_CONCURRENT_SSH=32
# Paramiko SSH keepalive interval (seconds); set 0 to disable. # Paramiko SSH keepalive interval (seconds); set 0 to disable.
SSH_KEEPALIVE_INTERVAL=30 SSH_KEEPALIVE_INTERVAL=15
# WebSocket keepalive interval (seconds); server sends traffic to avoid proxy idle timeouts.
WS_KEEPALIVE_INTERVAL=25
-21
View File
@@ -1,21 +0,0 @@
name: CI
on:
push:
branches: [ main ]
workflow_dispatch:
jobs:
build-and-push:
name: Build and Push
runs-on: build-htz-01
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build and push Docker image
run: |
docker build -t cr.jdbnet.co.uk/public/ssh:latest .
docker push cr.jdbnet.co.uk/public/ssh:latest
+46
View File
@@ -0,0 +1,46 @@
name: CI
on:
workflow_dispatch:
jobs:
build-and-push:
name: Build and Push
runs-on: build-htz-01
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build and push Docker image
run: |
docker build -t cr.jdbnet.co.uk/public/ssh:dev .
docker push cr.jdbnet.co.uk/public/ssh:dev
sonarqube:
name: SonarQube
runs-on: build-htz-01
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create Valid Project Key
id: sonar_setup
run: |
CLEAN_KEY=$(echo "${{ gitea.repository }}" | tr '/' ':')
echo "key=$CLEAN_KEY" >> $GITHUB_OUTPUT
- name: SonarQube Scan
uses: sonarsource/sonarqube-scan-action@master
continue-on-error: true
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
with:
args: >
-Dsonar.projectKey=${{ steps.sonar_setup.outputs.key }}
-Dsonar.projectName=${{ gitea.repository }}
-Dsonar.qualitygate.wait=true
+46
View File
@@ -0,0 +1,46 @@
name: Release
on:
pull_request:
branches:
- main
types: [closed]
jobs:
release:
if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'v')
runs-on: build-htz-01
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract Version
id: get_version
run: echo "VERSION=${{ github.head_ref }}" >> $GITHUB_OUTPUT
- name: Generate Changelog
id: changelog
uses: https://github.com/metcalfc/changelog-generator@v4.6.2
with:
myToken: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
run: |
VERSION=${{ steps.get_version.outputs.VERSION }}
docker build -t cr.jdbnet.co.uk/public/ssh:$VERSION \
-t cr.jdbnet.co.uk/public/ssh:latest \
--build-arg VERSION=$VERSION \
.
docker push cr.jdbnet.co.uk/public/ssh:$VERSION
docker push cr.jdbnet.co.uk/public/ssh:latest
- name: Create Gitea Release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
tag_name: ${{ steps.get_version.outputs.VERSION }}
name: ${{ steps.get_version.outputs.VERSION }}
body: ${{ steps.changelog.outputs.changelog }}
draft: false
prerelease: false
+2
View File
@@ -8,6 +8,8 @@ RUN npm run build
FROM python:3.14-slim FROM python:3.14-slim
LABEL org.opencontainers.image.vendor="JDB-NET" LABEL org.opencontainers.image.vendor="JDB-NET"
WORKDIR /app WORKDIR /app
ARG VERSION=unknown
ENV VERSION=${VERSION}
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . /app COPY . /app
+2 -1
View File
@@ -9,6 +9,8 @@ A modern, browser-based SSH workspace for managing remote access in one place.
JDB-NET SSH gives you a clean web interface for opening secure shell sessions, organising hosts, and transferring files over SFTP without juggling multiple desktop tools. It is built for day-to-day server access with a fast tabbed terminal experience, reusable connection identities, and straightforward navigation for both occasional and frequent use. JDB-NET SSH gives you a clean web interface for opening secure shell sessions, organising hosts, and transferring files over SFTP without juggling multiple desktop tools. It is built for day-to-day server access with a fast tabbed terminal experience, reusable connection identities, and straightforward navigation for both occasional and frequent use.
![Screenshot](img/screenshot.png)
## Features ## Features
- Secure sign-in before accessing hosts and sessions - Secure sign-in before accessing hosts and sessions
@@ -28,7 +30,6 @@ services:
ports: ports:
- "5000:5000" - "5000:5000"
environment: environment:
GEVENT_MONKEY_PATCH: "1"
MYSQL_HOST: "<YOUR_MYSQL_HOST>" MYSQL_HOST: "<YOUR_MYSQL_HOST>"
MYSQL_DATABASE: "<YOUR_MYSQL_DATABASE>" MYSQL_DATABASE: "<YOUR_MYSQL_DATABASE>"
MYSQL_USER: "<YOUR_MYSQL_USER>" MYSQL_USER: "<YOUR_MYSQL_USER>"
+822 -65
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,7 +5,7 @@
<link <link
rel="icon" rel="icon"
type="image/png" type="image/png"
href="https://assets.jdbnet.co.uk/ssh.png" href="https://assets.jdbnet.co.uk/projects/ssh.png"
/> />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0f1419" /> <meta name="theme-color" content="#0f1419" />
+9
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"lucide-vue-next": "^1.0.0",
"pinia": "^2.2.6", "pinia": "^2.2.6",
"vue": "^3.5.13" "vue": "^3.5.13"
}, },
@@ -1609,6 +1610,14 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true "dev": true
}, },
"node_modules/lucide-vue-next": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz",
"integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==",
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+1
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"lucide-vue-next": "^1.0.0",
"pinia": "^2.2.6", "pinia": "^2.2.6",
"vue": "^3.5.13" "vue": "^3.5.13"
}, },
+886 -73
View File
File diff suppressed because it is too large Load Diff
+112 -18
View File
@@ -21,7 +21,7 @@ function browseParams(folderId: number | null, q: string): string {
} }
export const api = { export const api = {
async me(): Promise<{ logged_in: boolean }> { async me(): Promise<{ logged_in: boolean; app_version?: string }> {
const res = await fetch("/api/me", { credentials: "include" }); const res = await fetch("/api/me", { credentials: "include" });
return handle(res); return handle(res);
}, },
@@ -65,6 +65,12 @@ export const api = {
return d.items; return d.items;
}, },
async listTags(): Promise<string[]> {
const res = await fetch("/api/tags", { credentials: "include" });
const d = await handle<{ items: string[] }>(res);
return d.items;
},
async listFoldersFlat(): Promise<FolderRow[]> { async listFoldersFlat(): Promise<FolderRow[]> {
const res = await fetch("/api/folders", { credentials: "include" }); const res = await fetch("/api/folders", { credentials: "include" });
const d = await handle<{ items: FolderRow[] }>(res); const d = await handle<{ items: FolderRow[] }>(res);
@@ -92,20 +98,29 @@ export const api = {
await handle(res); await handle(res);
}, },
async updateFolder(
id: number,
body: {
label?: string;
parent_id?: number | null;
},
): Promise<void> {
const res = await fetch(`/api/folders/${id}`, {
method: "PATCH",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
await handle(res);
},
async listIdentities(): Promise<IdentityRow[]> { async listIdentities(): Promise<IdentityRow[]> {
const res = await fetch("/api/identities", { credentials: "include" }); const res = await fetch("/api/identities", { credentials: "include" });
const d = await handle<{ items: IdentityRow[] }>(res); const d = await handle<{ items: IdentityRow[] }>(res);
return d.items; return d.items;
}, },
async createHost(body: { async createHost(body: Record<string, unknown>): Promise<{ id: number }> {
label: string;
hostname: string;
port?: number;
identity_id: number;
folder_id?: number | null;
jump_host_id?: number | null;
}): Promise<{ id: number }> {
const res = await fetch("/api/hosts", { const res = await fetch("/api/hosts", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
@@ -117,14 +132,7 @@ export const api = {
async patchHost( async patchHost(
id: number, id: number,
body: Partial<{ body: Record<string, unknown>,
label: string;
hostname: string;
port: number;
identity_id: number;
folder_id: number | null;
jump_host_id: number | null;
}>,
): Promise<void> { ): Promise<void> {
const res = await fetch(`/api/hosts/${id}`, { const res = await fetch(`/api/hosts/${id}`, {
method: "PATCH", method: "PATCH",
@@ -153,6 +161,25 @@ export const api = {
return handle(res); return handle(res);
}, },
async updateIdentity(
id: number,
body: Partial<{
label: string;
ssh_username: string;
password: string;
private_key: string;
key_passphrase: string;
}>,
): Promise<void> {
const res = await fetch(`/api/identities/${id}`, {
method: "PATCH",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
await handle(res);
},
async deleteIdentity(id: number): Promise<void> { async deleteIdentity(id: number): Promise<void> {
const res = await fetch(`/api/identities/${id}`, { const res = await fetch(`/api/identities/${id}`, {
method: "DELETE", method: "DELETE",
@@ -161,8 +188,11 @@ export const api = {
await handle(res); await handle(res);
}, },
async listConnectionAudit(limit = 200): Promise<ConnectionAuditRow[]> { async listConnectionAudit(limit = 200, daysBack?: number): Promise<ConnectionAuditRow[]> {
const q = new URLSearchParams({ limit: String(limit) }); const q = new URLSearchParams({ limit: String(limit) });
if (daysBack !== undefined) {
q.set("days_back", String(daysBack));
}
const res = await fetch(`/api/audit/connections?${q.toString()}`, { const res = await fetch(`/api/audit/connections?${q.toString()}`, {
credentials: "include", credentials: "include",
}); });
@@ -170,6 +200,40 @@ export const api = {
return d.items; return d.items;
}, },
async listApiKeyScopes(): Promise<ApiKeyScopeDef[]> {
const res = await fetch("/api/api-keys/scopes", { credentials: "include" });
const d = await handle<{ items: ApiKeyScopeDef[] }>(res);
return d.items;
},
async listApiKeys(): Promise<ApiKeyRow[]> {
const res = await fetch("/api/api-keys", { credentials: "include" });
const d = await handle<{ items: ApiKeyRow[] }>(res);
return d.items;
},
async createApiKey(body: {
label: string;
scopes: string[];
expires_at?: string | null;
}): Promise<CreateApiKeyResponse> {
const res = await fetch("/api/api-keys", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
return handle(res);
},
async revokeApiKey(id: number): Promise<void> {
const res = await fetch(`/api/api-keys/${id}`, {
method: "DELETE",
credentials: "include",
});
await handle(res);
},
async sftpList( async sftpList(
connId: string, connId: string,
path: string, path: string,
@@ -253,6 +317,8 @@ export interface HostRow {
identity_label: string; identity_label: string;
identity_auth_type: string; identity_auth_type: string;
folder_label?: string | null; folder_label?: string | null;
last_connected_at?: string | null;
tags?: string[];
} }
export interface IdentityRow { export interface IdentityRow {
@@ -279,3 +345,31 @@ export interface ConnectionAuditRow {
ended_at: string | null; ended_at: string | null;
duration_seconds: number | null; duration_seconds: number | null;
} }
export interface ApiKeyScopeDef {
id: string;
label: string;
description: string;
}
export interface ApiKeyRow {
id: number;
label: string;
key_prefix: string;
scopes: string[];
expires_at: string | null;
last_used_at: string | null;
revoked_at: string | null;
created_at: string;
expired: boolean;
active: boolean;
}
export interface CreateApiKeyResponse {
id: number;
label: string;
key_prefix: string;
scopes: string[];
expires_at: string | null;
key: string;
}
+14 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from "vue"; import { ref, watch } from "vue";
import { File, Folder } from "lucide-vue-next";
import { api, type SftpEntry } from "@/api"; import { api, type SftpEntry } from "@/api";
const props = defineProps<{ connId: string }>(); const props = defineProps<{ connId: string }>();
@@ -198,7 +199,19 @@ function fmtSize(n: number): string {
:class="isDir(e.st_mode) ? 'text-accent' : 'text-slate-200'" :class="isDir(e.st_mode) ? 'text-accent' : 'text-slate-200'"
@click="enter(e)" @click="enter(e)"
> >
{{ isDir(e.st_mode) ? "📁" : "📄" }} {{ e.filename }} <span class="inline-flex items-center gap-1.5">
<Folder
v-if="isDir(e.st_mode)"
class="h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<File
v-else
class="h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>{{ e.filename }}</span>
</span>
</button> </button>
<span class="shrink-0 text-[10px] text-slate-500">{{ <span class="shrink-0 text-[10px] text-slate-500">{{
isDir(e.st_mode) ? "" : fmtSize(e.st_size) isDir(e.st_mode) ? "" : fmtSize(e.st_size)
+40 -12
View File
@@ -24,6 +24,7 @@ let ws: WebSocket | null = null;
let term: Terminal | null = null; let term: Terminal | null = null;
let fit: FitAddon | null = null; let fit: FitAddon | null = null;
let ro: ResizeObserver | null = null; let ro: ResizeObserver | null = null;
let visibilityHandler: (() => void) | null = null;
function wsUrl(hostId: number): string { function wsUrl(hostId: number): string {
const proto = location.protocol === "https:" ? "wss:" : "ws:"; const proto = location.protocol === "https:" ? "wss:" : "ws:";
@@ -36,6 +37,12 @@ function sendResize() {
ws.send(JSON.stringify({ type: "resize", ...dims })); ws.send(JSON.stringify({ type: "resize", ...dims }));
} }
function sendPing() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }));
}
}
function fitAndResize() { function fitAndResize() {
if (!fit || !term || !props.visible) return; if (!fit || !term || !props.visible) return;
try { try {
@@ -46,6 +53,25 @@ function fitAndResize() {
} }
} }
function isControlMessage(raw: string): boolean {
try {
const o = JSON.parse(raw) as { type?: string; conn_id?: string };
if (o.type === "ready" && o.conn_id) {
connId.value = o.conn_id;
status.value = "";
fitAndResize();
term?.focus();
return true;
}
if (o.type === "keepalive" || o.type === "pong") {
return true;
}
} catch {
/* not JSON control traffic */
}
return false;
}
onMounted(async () => { onMounted(async () => {
await nextTick(); await nextTick();
if (!termEl.value) return; if (!termEl.value) return;
@@ -91,18 +117,7 @@ onMounted(async () => {
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
if (!term) return; if (!term) return;
if (typeof ev.data === "string") { if (typeof ev.data === "string") {
try { if (isControlMessage(ev.data)) return;
const o = JSON.parse(ev.data) as { type?: string; conn_id?: string };
if (o.type === "ready" && o.conn_id) {
connId.value = o.conn_id;
status.value = "";
fitAndResize();
term.focus();
return;
}
} catch {
/* fall through */
}
term.write(ev.data); term.write(ev.data);
return; return;
} }
@@ -121,9 +136,21 @@ onMounted(async () => {
status.value = "Session ended"; status.value = "Session ended";
} }
}; };
visibilityHandler = () => {
if (document.visibilityState === "visible") {
sendPing();
fitAndResize();
}
};
document.addEventListener("visibilitychange", visibilityHandler);
}); });
onUnmounted(() => { onUnmounted(() => {
if (visibilityHandler) {
document.removeEventListener("visibilitychange", visibilityHandler);
visibilityHandler = null;
}
ro?.disconnect(); ro?.disconnect();
ro = null; ro = null;
ws?.close(); ws?.close();
@@ -140,6 +167,7 @@ watch(
await nextTick(); await nextTick();
fitAndResize(); fitAndResize();
term?.focus(); term?.focus();
sendPing();
} }
}, },
); );
+97
View File
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, ref } from "vue";
const props = defineProps<{
modelValue: string;
suggestions: string[];
}>();
const emit = defineEmits<{
"update:modelValue": [value: string];
}>();
const focused = ref(false);
function normalizeTag(raw: string): string {
return raw.trim().toLowerCase().replace(/\s+/g, "");
}
function selectedTags(): Set<string> {
return new Set(parseTagsInput(props.modelValue));
}
function parseTagsInput(raw: string): string[] {
const seen = new Set<string>();
const tags: string[] = [];
for (const part of raw.split(",")) {
const name = normalizeTag(part);
if (!name || seen.has(name)) continue;
seen.add(name);
tags.push(name);
}
return tags;
}
function currentPartial(): string {
const val = props.modelValue;
const idx = val.lastIndexOf(",");
return normalizeTag(idx >= 0 ? val.slice(idx + 1) : val);
}
const filteredSuggestions = computed(() => {
const partial = currentPartial();
const selected = selectedTags();
return props.suggestions
.filter((tag) => {
if (selected.has(tag)) return false;
if (!partial) return true;
return tag.includes(partial);
})
.slice(0, 8);
});
const showSuggestions = computed(
() => focused.value && filteredSuggestions.value.length > 0,
);
function applySuggestion(tag: string) {
const val = props.modelValue;
const idx = val.lastIndexOf(",");
const prefix = idx >= 0 ? `${val.slice(0, idx + 1)} ` : "";
emit("update:modelValue", `${prefix}${tag}, `);
}
function onBlur() {
window.setTimeout(() => {
focused.value = false;
}, 150);
}
</script>
<template>
<div class="relative">
<input
:value="modelValue"
placeholder="buildagents, prod"
autocomplete="off"
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@focus="focused = true"
@blur="onBlur"
/>
<ul
v-if="showSuggestions"
class="absolute z-20 mt-1 max-h-40 w-full overflow-auto rounded-lg border border-slate-700 bg-surface-raised py-1 shadow-lg"
>
<li v-for="tag in filteredSuggestions" :key="tag">
<button
type="button"
class="block w-full px-3 py-1.5 text-left text-sm text-slate-200 hover:bg-slate-800"
@mousedown.prevent="applySuggestion(tag)"
>
{{ tag }}
</button>
</li>
</ul>
</div>
</template>
Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

+6
View File
@@ -0,0 +1,6 @@
{
"name": "ssh",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}