mirror of
https://github.com/open-webui/open-webui.git
synced 2026-04-28 11:38:38 -05:00
refac
This commit is contained in:
@@ -112,3 +112,74 @@ export const uploadToTerminal = async (
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
export const createDirectory = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
path: string
|
||||
): Promise<{ path: string } | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/mkdir`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ path })
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('open-terminal createDirectory error:', err);
|
||||
return null;
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
export const deleteEntry = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
path: string
|
||||
): Promise<{ path: string; type: string } | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/delete?path=${encodeURIComponent(path)}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('open-terminal deleteEntry error:', err);
|
||||
return null;
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
export const setCwd = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
path: string
|
||||
): Promise<{ cwd: string } | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ path })
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('open-terminal setCwd error:', err);
|
||||
return null;
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
showEmbeds
|
||||
} from '$lib/stores';
|
||||
|
||||
import { getCwd } from '$lib/apis/terminal';
|
||||
import {
|
||||
convertMessagesToHistory,
|
||||
copyToClipboard,
|
||||
@@ -2126,6 +2127,22 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Build terminal servers with current CWD injected into run_command descriptions
|
||||
const terminalServersWithCwd = await (async () => {
|
||||
const terminals = JSON.parse(JSON.stringify($terminalServers ?? []));
|
||||
const configs = ($settings?.terminalServers ?? []).filter((s) => s.enabled);
|
||||
await Promise.all(
|
||||
configs.map(async (t) => {
|
||||
const cwd = await getCwd(t.url, t.key ?? '').catch(() => null);
|
||||
if (!cwd) return;
|
||||
const server = terminals.find((s) => s.url === t.url);
|
||||
const spec = server?.specs?.find((s) => s.name === 'run_command');
|
||||
if (spec) spec.description += `\n\nThe current working directory is: ${cwd}`;
|
||||
})
|
||||
);
|
||||
return terminals;
|
||||
})();
|
||||
|
||||
const res = await generateOpenAIChatCompletion(
|
||||
localStorage.token,
|
||||
{
|
||||
@@ -2152,7 +2169,7 @@
|
||||
...($toolServers ?? []).filter(
|
||||
(server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id)
|
||||
),
|
||||
...($terminalServers ?? [])
|
||||
...terminalServersWithCwd
|
||||
],
|
||||
features: getFeatures(),
|
||||
variables: {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { getContext, onMount, onDestroy, tick, afterUpdate } from 'svelte';
|
||||
import { settings } from '$lib/stores';
|
||||
import {
|
||||
@@ -12,10 +13,20 @@
|
||||
readFile,
|
||||
downloadFileBlob,
|
||||
uploadToTerminal,
|
||||
createDirectory,
|
||||
deleteEntry,
|
||||
setCwd,
|
||||
type FileEntry
|
||||
} from '$lib/apis/terminal';
|
||||
import { DropdownMenu } from 'bits-ui';
|
||||
import { flyAndScale } from '$lib/utils/transitions';
|
||||
import Folder from '../icons/Folder.svelte';
|
||||
import NewFolderAlt from '../icons/NewFolderAlt.svelte';
|
||||
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
|
||||
import GarbageBin from '../icons/GarbageBin.svelte';
|
||||
import Spinner from '../common/Spinner.svelte';
|
||||
import Tooltip from '../common/Tooltip.svelte';
|
||||
import ConfirmDialog from '../common/ConfirmDialog.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
@@ -39,6 +50,14 @@
|
||||
let isDragOver = false;
|
||||
let uploading = false;
|
||||
|
||||
let creatingFolder = false;
|
||||
let newFolderName = '';
|
||||
let newFolderInput: HTMLInputElement;
|
||||
|
||||
let deleteTarget: { path: string; name: string } | null = null;
|
||||
let showDeleteConfirm = false;
|
||||
let shiftKey = false;
|
||||
|
||||
let breadcrumbEl: HTMLDivElement;
|
||||
|
||||
$: activeTerminal = ($settings?.terminalServers ?? []).find((s) => s.enabled);
|
||||
@@ -85,6 +104,9 @@
|
||||
savedPath = path;
|
||||
const result = await listFiles(terminalUrl, terminalKey, path);
|
||||
loading = false;
|
||||
|
||||
// Set working directory on the terminal server (fire-and-forget)
|
||||
setCwd(terminalUrl, terminalKey, path);
|
||||
if (result === null) {
|
||||
error =
|
||||
'Failed to load directory. Check your Terminal connection in Settings → Integrations.';
|
||||
@@ -167,6 +189,36 @@
|
||||
await loadDir(currentPath);
|
||||
};
|
||||
|
||||
const startNewFolder = () => {
|
||||
creatingFolder = true;
|
||||
newFolderName = '';
|
||||
tick().then(() => newFolderInput?.focus());
|
||||
};
|
||||
|
||||
const submitNewFolder = async () => {
|
||||
const name = newFolderName.trim();
|
||||
creatingFolder = false;
|
||||
newFolderName = '';
|
||||
if (!name) return;
|
||||
const result = await createDirectory(terminalUrl, terminalKey, `${currentPath}${name}`);
|
||||
if (result) {
|
||||
toast.success($i18n.t('Folder created'));
|
||||
} else {
|
||||
toast.error($i18n.t('Failed to create folder'));
|
||||
}
|
||||
await loadDir(currentPath);
|
||||
};
|
||||
|
||||
const handleDelete = async (path: string, name: string) => {
|
||||
const result = await deleteEntry(terminalUrl, terminalKey, path);
|
||||
if (result) {
|
||||
toast.success($i18n.t('{{name}} deleted', { name }));
|
||||
} else {
|
||||
toast.error($i18n.t('Failed to delete {{name}}', { name }));
|
||||
}
|
||||
await loadDir(currentPath);
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if (!configured) return;
|
||||
// On first ever open, resolve the server's CWD instead of defaulting to /
|
||||
@@ -175,6 +227,24 @@
|
||||
if (cwd) savedPath = cwd.endsWith('/') ? cwd : cwd + '/';
|
||||
}
|
||||
loadDir(savedPath);
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') shiftKey = true;
|
||||
};
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') shiftKey = false;
|
||||
};
|
||||
const onBlur = () => (shiftKey = false);
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener('blur', onBlur);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -183,6 +253,16 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteConfirm}
|
||||
on:confirm={() => {
|
||||
if (deleteTarget) {
|
||||
handleDelete(deleteTarget.path, deleteTarget.name);
|
||||
deleteTarget = null;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if !configured}
|
||||
<div class="flex-1 flex flex-col items-center justify-center p-6 text-center gap-3">
|
||||
<Folder className="size-10 text-gray-300 dark:text-gray-600" />
|
||||
@@ -224,33 +304,49 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Breadcrumb — always visible, scrolls to end -->
|
||||
<div
|
||||
bind:this={breadcrumbEl}
|
||||
class="flex items-center px-2 pb-1.5 shrink-0 overflow-x-auto scrollbar-hidden"
|
||||
>
|
||||
{#each breadcrumbs as crumb, i}
|
||||
{#if i > 1}
|
||||
<!-- Breadcrumb + actions — always visible, scrolls to end -->
|
||||
<div class="flex items-center px-2 pb-1.5 shrink-0 gap-1">
|
||||
<div
|
||||
bind:this={breadcrumbEl}
|
||||
class="flex items-center flex-1 min-w-0 overflow-x-auto scrollbar-none"
|
||||
>
|
||||
{#each breadcrumbs as crumb, i}
|
||||
{#if i > 1}
|
||||
<span class="text-gray-300 dark:text-gray-600 text-xs shrink-0 select-none mx-0.5"
|
||||
>/</span
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
class="text-xs shrink-0 px-1 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition
|
||||
{!selectedFile && i === breadcrumbs.length - 1
|
||||
? 'text-gray-700 dark:text-gray-300'
|
||||
: 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400'}"
|
||||
on:click={() => loadDir(crumb.path)}
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
{/each}
|
||||
{#if selectedFile}
|
||||
<span class="text-gray-300 dark:text-gray-600 text-xs shrink-0 select-none mx-0.5">/</span
|
||||
>
|
||||
<span
|
||||
class="text-xs shrink-0 px-1.5 py-0.5 text-gray-700 dark:text-gray-300 truncate max-w-[120px]"
|
||||
>
|
||||
{selectedFile.split('/').pop()}
|
||||
</span>
|
||||
{/if}
|
||||
<button
|
||||
class="text-xs shrink-0 px-1 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition
|
||||
{!selectedFile && i === breadcrumbs.length - 1
|
||||
? 'text-gray-700 dark:text-gray-300'
|
||||
: 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400'}"
|
||||
on:click={() => loadDir(crumb.path)}
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
{/each}
|
||||
{#if selectedFile}
|
||||
<span class="text-gray-300 dark:text-gray-600 text-xs shrink-0 select-none mx-0.5">/</span>
|
||||
<span
|
||||
class="text-xs shrink-0 px-1.5 py-0.5 text-gray-700 dark:text-gray-300 truncate max-w-[120px]"
|
||||
>
|
||||
{selectedFile.split('/').pop()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if !selectedFile}
|
||||
<Tooltip content={$i18n.t('New Folder')}>
|
||||
<button
|
||||
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
|
||||
on:click={startNewFolder}
|
||||
aria-label={$i18n.t('New Folder')}
|
||||
>
|
||||
<NewFolderAlt className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -308,59 +404,150 @@
|
||||
<div class="flex justify-center pt-8"><Spinner className="size-5" /></div>
|
||||
{:else if error}
|
||||
<div class="p-4 text-xs text-red-500 dark:text-red-400">{error}</div>
|
||||
{:else if entries.length === 0}
|
||||
{:else if entries.length === 0 && !creatingFolder}
|
||||
<div class="p-4 text-xs text-gray-400 text-center">
|
||||
{$i18n.t('Empty — drop files here to upload')}
|
||||
</div>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each entries as entry}
|
||||
<li>
|
||||
<button
|
||||
class="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800 transition text-left"
|
||||
draggable={entry.type === 'file'}
|
||||
on:dragstart={(e) => {
|
||||
if (entry.type !== 'file') return;
|
||||
e.dataTransfer?.setData(
|
||||
'application/x-terminal-file',
|
||||
JSON.stringify({
|
||||
path: `${currentPath}${entry.name}`,
|
||||
name: entry.name,
|
||||
url: terminalUrl,
|
||||
key: terminalKey
|
||||
})
|
||||
);
|
||||
}}
|
||||
on:click={() => openEntry(entry)}
|
||||
>
|
||||
{#if entry.type === 'directory'}
|
||||
<Folder className="size-4 shrink-0 text-blue-400 dark:text-blue-300" />
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="size-4 shrink-0 text-gray-400"
|
||||
{/if}
|
||||
|
||||
{#if !loading && !error && !uploading && !selectedFile}
|
||||
{#if creatingFolder}
|
||||
<div class="flex items-center gap-2 px-3 py-1.5">
|
||||
<Folder className="size-4 shrink-0 text-blue-400 dark:text-blue-300" />
|
||||
<input
|
||||
bind:this={newFolderInput}
|
||||
bind:value={newFolderName}
|
||||
class="flex-1 text-xs bg-transparent border border-gray-200 dark:border-gray-700 rounded px-1.5 py-0.5 outline-none focus:border-blue-400 dark:focus:border-blue-500"
|
||||
placeholder={$i18n.t('Folder name')}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') submitNewFolder();
|
||||
if (e.key === 'Escape') {
|
||||
creatingFolder = false;
|
||||
newFolderName = '';
|
||||
}
|
||||
}}
|
||||
on:blur={submitNewFolder}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if entries.length > 0 || creatingFolder}
|
||||
<ul>
|
||||
{#each entries as entry}
|
||||
<li class="group">
|
||||
<div
|
||||
class="w-full flex items-center hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
||||
>
|
||||
<button
|
||||
class="flex-1 flex items-center gap-2 px-3 py-1.5 text-left min-w-0"
|
||||
draggable={entry.type === 'file'}
|
||||
on:dragstart={(e) => {
|
||||
if (entry.type !== 'file') return;
|
||||
e.dataTransfer?.setData(
|
||||
'application/x-terminal-file',
|
||||
JSON.stringify({
|
||||
path: `${currentPath}${entry.name}`,
|
||||
name: entry.name,
|
||||
url: terminalUrl,
|
||||
key: terminalKey
|
||||
})
|
||||
);
|
||||
}}
|
||||
on:click={() => openEntry(entry)}
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="flex-1 text-xs text-gray-800 dark:text-gray-200 truncate">
|
||||
{entry.name}
|
||||
</span>
|
||||
{#if entry.type === 'file' && entry.size !== undefined}
|
||||
<span class="text-xs text-gray-400 shrink-0">{formatSize(entry.size)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if entry.type === 'directory'}
|
||||
<Folder className="size-4 shrink-0 text-blue-400 dark:text-blue-300" />
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="size-4 shrink-0 text-gray-400"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="flex-1 text-xs text-gray-800 dark:text-gray-200 truncate">
|
||||
{entry.name}
|
||||
</span>
|
||||
{#if entry.type === 'file' && entry.size !== undefined}
|
||||
<span class="text-xs text-gray-400 shrink-0">{formatSize(entry.size)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="shrink-0 p-0.5 mr-1 rounded-lg transition
|
||||
text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400
|
||||
hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
aria-label={$i18n.t('More')}
|
||||
>
|
||||
<EllipsisHorizontal className="size-3.5" />
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content
|
||||
strategy="fixed"
|
||||
class="w-full max-w-[150px] rounded-2xl p-1 z-[9999999] bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-100 dark:border-gray-800"
|
||||
sideOffset={4}
|
||||
side="bottom"
|
||||
align="end"
|
||||
transition={flyAndScale}
|
||||
>
|
||||
{#if entry.type !== "directory"}
|
||||
<DropdownMenu.Item
|
||||
type="button"
|
||||
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadFile(`${currentPath}${entry.name}`);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129V2.75Z"
|
||||
/>
|
||||
<path
|
||||
d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="flex items-center">{$i18n.t('Download')}</div>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Item
|
||||
type="button"
|
||||
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteTarget = {
|
||||
path: `${currentPath}${entry.name}`,
|
||||
name: entry.name
|
||||
};
|
||||
showDeleteConfirm = true;
|
||||
}}
|
||||
>
|
||||
<GarbageBin className="size-4" />
|
||||
<div class="flex items-center">{$i18n.t('Delete')}</div>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
16
src/lib/components/icons/NewFolderAlt.svelte
Normal file
16
src/lib/components/icons/NewFolderAlt.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
export let className = 'size-4';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class={className}
|
||||
>
|
||||
<path d="M18 6H20M22 6H20M20 6V4M20 6V8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M21.4 20H2.6C2.26863 20 2 19.7314 2 19.4V11H21.4C21.7314 11 22 11.2686 22 11.6V19.4C22 19.7314 21.7314 20 21.4 20Z" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M2 11V4.6C2 4.26863 2.26863 4 2.6 4H8.77805C8.92127 4 9.05977 4.05124 9.16852 4.14445L12.3315 6.85555C12.4402 6.94876 12.5787 7 12.722 7H14" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
@@ -153,6 +153,7 @@
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
terminalServers.set(terminalServersData);
|
||||
} else {
|
||||
terminalServers.set([]);
|
||||
|
||||
Reference in New Issue
Block a user