This commit is contained in:
Timothy Jaeryang Baek
2026-02-25 15:52:12 -06:00
parent 345f3e3559
commit 64ff15a536
5 changed files with 366 additions and 74 deletions
+71
View File
@@ -112,3 +112,74 @@ export const uploadToTerminal = async (
}); });
return res; 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;
};
+18 -1
View File
@@ -46,6 +46,7 @@
showEmbeds showEmbeds
} from '$lib/stores'; } from '$lib/stores';
import { getCwd } from '$lib/apis/terminal';
import { import {
convertMessagesToHistory, convertMessagesToHistory,
copyToClipboard, 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( const res = await generateOpenAIChatCompletion(
localStorage.token, localStorage.token,
{ {
@@ -2152,7 +2169,7 @@
...($toolServers ?? []).filter( ...($toolServers ?? []).filter(
(server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id) (server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id)
), ),
...($terminalServers ?? []) ...terminalServersWithCwd
], ],
features: getFeatures(), features: getFeatures(),
variables: { variables: {
+260 -73
View File
@@ -4,6 +4,7 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount, onDestroy, tick, afterUpdate } from 'svelte'; import { getContext, onMount, onDestroy, tick, afterUpdate } from 'svelte';
import { settings } from '$lib/stores'; import { settings } from '$lib/stores';
import { import {
@@ -12,10 +13,20 @@
readFile, readFile,
downloadFileBlob, downloadFileBlob,
uploadToTerminal, uploadToTerminal,
createDirectory,
deleteEntry,
setCwd,
type FileEntry type FileEntry
} from '$lib/apis/terminal'; } from '$lib/apis/terminal';
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import Folder from '../icons/Folder.svelte'; 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 Spinner from '../common/Spinner.svelte';
import Tooltip from '../common/Tooltip.svelte';
import ConfirmDialog from '../common/ConfirmDialog.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@@ -39,6 +50,14 @@
let isDragOver = false; let isDragOver = false;
let uploading = 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; let breadcrumbEl: HTMLDivElement;
$: activeTerminal = ($settings?.terminalServers ?? []).find((s) => s.enabled); $: activeTerminal = ($settings?.terminalServers ?? []).find((s) => s.enabled);
@@ -85,6 +104,9 @@
savedPath = path; savedPath = path;
const result = await listFiles(terminalUrl, terminalKey, path); const result = await listFiles(terminalUrl, terminalKey, path);
loading = false; loading = false;
// Set working directory on the terminal server (fire-and-forget)
setCwd(terminalUrl, terminalKey, path);
if (result === null) { if (result === null) {
error = error =
'Failed to load directory. Check your Terminal connection in Settings → Integrations.'; 'Failed to load directory. Check your Terminal connection in Settings → Integrations.';
@@ -167,6 +189,36 @@
await loadDir(currentPath); 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 () => { onMount(async () => {
if (!configured) return; if (!configured) return;
// On first ever open, resolve the server's CWD instead of defaulting to / // On first ever open, resolve the server's CWD instead of defaulting to /
@@ -175,6 +227,24 @@
if (cwd) savedPath = cwd.endsWith('/') ? cwd : cwd + '/'; if (cwd) savedPath = cwd.endsWith('/') ? cwd : cwd + '/';
} }
loadDir(savedPath); 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(() => { onDestroy(() => {
@@ -183,6 +253,16 @@
}); });
</script> </script>
<ConfirmDialog
bind:show={showDeleteConfirm}
on:confirm={() => {
if (deleteTarget) {
handleDelete(deleteTarget.path, deleteTarget.name);
deleteTarget = null;
}
}}
/>
{#if !configured} {#if !configured}
<div class="flex-1 flex flex-col items-center justify-center p-6 text-center gap-3"> <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" /> <Folder className="size-10 text-gray-300 dark:text-gray-600" />
@@ -224,33 +304,49 @@
</div> </div>
{/if} {/if}
<!-- Breadcrumb — always visible, scrolls to end --> <!-- Breadcrumb + actions — always visible, scrolls to end -->
<div <div class="flex items-center px-2 pb-1.5 shrink-0 gap-1">
bind:this={breadcrumbEl} <div
class="flex items-center px-2 pb-1.5 shrink-0 overflow-x-auto scrollbar-hidden" 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} {#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-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} {/if}
<button </div>
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 {#if !selectedFile}
? 'text-gray-700 dark:text-gray-300' <Tooltip content={$i18n.t('New Folder')}>
: 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400'}" <button
on:click={() => loadDir(crumb.path)} 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}
{crumb.label} aria-label={$i18n.t('New Folder')}
</button> >
{/each} <NewFolderAlt className="size-3.5" />
{#if selectedFile} </button>
<span class="text-gray-300 dark:text-gray-600 text-xs shrink-0 select-none mx-0.5">/</span> </Tooltip>
<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} {/if}
</div> </div>
@@ -308,59 +404,150 @@
<div class="flex justify-center pt-8"><Spinner className="size-5" /></div> <div class="flex justify-center pt-8"><Spinner className="size-5" /></div>
{:else if error} {:else if error}
<div class="p-4 text-xs text-red-500 dark:text-red-400">{error}</div> <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"> <div class="p-4 text-xs text-gray-400 text-center">
{$i18n.t('Empty — drop files here to upload')} {$i18n.t('Empty — drop files here to upload')}
</div> </div>
{:else} {/if}
<ul>
{#each entries as entry} {#if !loading && !error && !uploading && !selectedFile}
<li> {#if creatingFolder}
<button <div class="flex items-center gap-2 px-3 py-1.5">
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" <Folder className="size-4 shrink-0 text-blue-400 dark:text-blue-300" />
draggable={entry.type === 'file'} <input
on:dragstart={(e) => { bind:this={newFolderInput}
if (entry.type !== 'file') return; bind:value={newFolderName}
e.dataTransfer?.setData( 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"
'application/x-terminal-file', placeholder={$i18n.t('Folder name')}
JSON.stringify({ on:keydown={(e) => {
path: `${currentPath}${entry.name}`, if (e.key === 'Enter') submitNewFolder();
name: entry.name, if (e.key === 'Escape') {
url: terminalUrl, creatingFolder = false;
key: terminalKey newFolderName = '';
}) }
); }}
}} on:blur={submitNewFolder}
on:click={() => openEntry(entry)} />
> </div>
{#if entry.type === 'directory'} {/if}
<Folder className="size-4 shrink-0 text-blue-400 dark:text-blue-300" />
{:else} {#if entries.length > 0 || creatingFolder}
<svg <ul>
xmlns="http://www.w3.org/2000/svg" {#each entries as entry}
viewBox="0 0 24 24" <li class="group">
fill="none" <div
stroke="currentColor" class="w-full flex items-center hover:bg-gray-50 dark:hover:bg-gray-800 transition"
stroke-width="1.5" >
class="size-4 shrink-0 text-gray-400" <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 {#if entry.type === 'directory'}
stroke-linecap="round" <Folder className="size-4 shrink-0 text-blue-400 dark:text-blue-300" />
stroke-linejoin="round" {:else}
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
/> xmlns="http://www.w3.org/2000/svg"
</svg> viewBox="0 0 24 24"
{/if} fill="none"
<span class="flex-1 text-xs text-gray-800 dark:text-gray-200 truncate"> stroke="currentColor"
{entry.name} stroke-width="1.5"
</span> class="size-4 shrink-0 text-gray-400"
{#if entry.type === 'file' && entry.size !== undefined} >
<span class="text-xs text-gray-400 shrink-0">{formatSize(entry.size)}</span> <path
{/if} stroke-linecap="round"
</button> stroke-linejoin="round"
</li> 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"
{/each} />
</ul> </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}
{/if} {/if}
</div> </div>
@@ -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>
+1
View File
@@ -153,6 +153,7 @@
} }
return true; return true;
}); });
terminalServers.set(terminalServersData); terminalServers.set(terminalServersData);
} else { } else {
terminalServers.set([]); terminalServers.set([]);