This commit is contained in:
Timothy Jaeryang Baek committed 2026-08-09 13:22:46 -06:00
1 parent 5caa91a493
commit 2dadc5435a
7 files changed
+101 -38

No files matched your search

+8 -2
View File
@@ -3,6 +3,12 @@ export type FileEntry = {
type: 'file' | 'directory';
size?: number;
modified?: number;
writable?: boolean;
};
export type TerminalFileList = {
entries: FileEntry[];
writable?: boolean;
};
export type ListeningPort = {
@@ -85,7 +91,7 @@ export const listFiles = async (
apiKey: string,
path: string = '/',
sessionId?: string
): Promise<FileEntry[] | null> => {
): Promise<TerminalFileList | null> => {
// The endpoint uses `directory` as the query param name
const url = `${baseUrl.replace(/\/$/, '')}/files/list?directory=${encodeURIComponent(path)}`;
const headers: Record<string, string> = bearerHeaders(apiKey);
@@ -99,7 +105,7 @@ export const listFiles = async (
console.error('open-terminal listFiles error:', err);
return null;
});
return res?.entries ?? null;
return res?.entries ? { entries: res.entries, writable: res.writable } : null;
};
export const readFile = async (
+39 -18
View File
@@ -93,6 +93,7 @@
let currentPath = savedPath;
let fileRoot: TerminalFileRoot | null = null;
let entries: FileEntry[] = [];
let currentWritable = true;
let loading = false;
let error: string | null = null;
@@ -175,6 +176,7 @@
// ── File preview state ───────────────────────────────────────────────
let selectedFile: string | null = null;
let selectedFileWritable = true;
let previewPort: number | null = null;
let fileContent: string | null = null;
let fileImageUrl: string | null = null;
@@ -410,6 +412,7 @@
loading = true;
error = null;
selectedFile = null;
selectedFileWritable = true;
previewPort = null;
clearFilePreview();
clearSelection();
@@ -428,7 +431,8 @@
'Failed to load directory. Check your Terminal connection in Settings → Integrations.';
entries = [];
} else {
entries = sortEntries(result);
currentWritable = result.writable !== false;
entries = sortEntries(result.entries);
}
};
@@ -439,6 +443,7 @@
}
const filePath = `${currentPath}${entry.name}`;
selectedFileWritable = entry.writable !== false;
pushNavHistory(currentPath, filePath);
const terminal = selectedTerminal;
@@ -566,6 +571,7 @@
// ── Drag-and-drop upload ─────────────────────────────────────────────
const handleDragOver = (e: DragEvent) => {
if (selectedFile) return;
if (!currentWritable) return;
if (!e.dataTransfer?.types.includes('Files')) return;
e.preventDefault();
e.stopPropagation();
@@ -578,7 +584,7 @@
isDragOver = false;
const terminal = selectedTerminal;
if (selectedFile || !terminal) return;
if (selectedFile || !terminal || !currentWritable) return;
const droppedFiles = Array.from(e.dataTransfer?.files ?? []);
if (!droppedFiles.length) return;
@@ -593,7 +599,7 @@
const handleUploadFiles = async (files: File[]) => {
const terminal = selectedTerminal;
if (!files.length || !terminal) return;
if (!files.length || !terminal || !currentWritable) return;
uploading = true;
for (const file of files) {
@@ -605,6 +611,7 @@
// ── Folder creation ──────────────────────────────────────────────────
const startNewFolder = async () => {
if (!currentWritable) return;
creatingFolder = true;
newFolderName = '';
await tick();
@@ -634,6 +641,7 @@
// ── File creation ────────────────────────────────────────────────────
const startNewFile = async () => {
if (!currentWritable) return;
creatingFile = true;
newFileName = '';
await tick();
@@ -658,7 +666,7 @@
// ── Delete ───────────────────────────────────────────────────────────
const handleDelete = async (path: string, name: string) => {
const terminal = selectedTerminal;
if (!terminal) return;
if (!terminal || !currentWritable) return;
const result = await deleteEntry(terminal.url, terminal.key, path, chatId ?? undefined);
toast[result ? 'success' : 'error'](
@@ -668,6 +676,7 @@
};
const requestDelete = (path: string, name: string) => {
if (!currentWritable) return;
deleteTarget = { path, name };
showDeleteConfirm = true;
};
@@ -675,7 +684,7 @@
// ── Move (drag-and-drop) ────────────────────────────────────────────
const handleMove = async (source: string, destFolder: string) => {
const terminal = selectedTerminal;
if (!terminal) return;
if (!terminal || !currentWritable) return;
const fileName = source.split('/').pop() ?? '';
const destination = `${destFolder}${fileName}`;
@@ -704,7 +713,7 @@
// ── Rename ──────────────────────────────────────────────────────────
const handleRename = async (oldPath: string, newName: string) => {
const terminal = selectedTerminal;
if (!terminal || !newName) return;
if (!terminal || !newName || !currentWritable) return;
const dir = oldPath.substring(0, oldPath.lastIndexOf('/') + 1) || currentPath;
const destination = `${dir}${newName}`;
@@ -733,6 +742,13 @@
$: selectedCount = selectedEntries.size;
$: hasSelectedFiles = [...selectedEntries].some((p) => !p.endsWith('/'));
$: selectedEntriesWritable =
currentWritable &&
[...selectedEntries].every((path) => {
const name = path.replace(/\/$/, '').split('/').pop();
const entry = entries.find((item) => item.name === name);
return entry?.writable !== false;
});
const clearSelection = () => {
selectedEntries = new Set();
@@ -792,7 +808,7 @@
const bulkDelete = async () => {
const terminal = selectedTerminal;
if (!terminal) return;
if (!terminal || !selectedEntriesWritable) return;
const paths = [...selectedEntries];
let ok = 0;
@@ -1046,6 +1062,7 @@
breadcrumbs={buildBreadcrumbs(currentPath)}
{selectedFile}
{loading}
writable={currentWritable}
{canGoBack}
{canGoForward}
{sortBy}
@@ -1143,9 +1160,9 @@
{#if isHtml && showRaw}
<Tooltip content={$i18n.t('Save')}>
<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"
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 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => filePreviewRef?.saveCodeFile()}
disabled={saving}
disabled={saving || !selectedFileWritable}
aria-label={$i18n.t('Save')}
>
{#if saving}
@@ -1171,9 +1188,9 @@
{:else if isMarkdown && showRaw}
<Tooltip content={$i18n.t('Save')}>
<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"
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 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => filePreviewRef?.saveCodeFile()}
disabled={saving}
disabled={saving || !selectedFileWritable}
aria-label={$i18n.t('Save')}
>
{#if saving}
@@ -1199,9 +1216,9 @@
{:else if isCode}
<Tooltip content={$i18n.t('Save')}>
<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"
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 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => filePreviewRef?.saveCodeFile()}
disabled={saving}
disabled={saving || !selectedFileWritable}
aria-label={$i18n.t('Save')}
>
{#if saving}
@@ -1243,9 +1260,9 @@
</Tooltip>
<Tooltip content={$i18n.t('Save')}>
<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"
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 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => filePreviewRef?.saveEdit()}
disabled={saving}
disabled={saving || !selectedFileWritable}
aria-label={$i18n.t('Save')}
>
{#if saving}
@@ -1269,8 +1286,9 @@
{:else}
<Tooltip content={$i18n.t('Edit')}>
<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"
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 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => filePreviewRef?.startEdit()}
disabled={!selectedFileWritable}
aria-label={$i18n.t('Edit')}
>
<PenAlt className="size-3.5" />
@@ -1309,7 +1327,7 @@
<Tooltip content={$i18n.t('Download')}>
<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={() => downloadFile(selectedFile)}
on:click={() => selectedFile && downloadFile(selectedFile)}
aria-label={$i18n.t('Download')}
>
<svg
@@ -1335,6 +1353,7 @@
<BulkActionBar
count={selectedCount}
hasFiles={hasSelectedFiles}
canDelete={selectedEntriesWritable}
onDelete={() => {
deleteTarget = { path: '__bulk__', name: `${selectedCount} items` };
showDeleteConfirm = true;
@@ -1370,6 +1389,7 @@
bind:saving
bind:currentSlide
{selectedFile}
readOnly={!selectedFileWritable}
{fileLoading}
{fileImageUrl}
{fileVideoUrl}
@@ -1394,7 +1414,7 @@
overlay={overlay || isDraggingHandle}
onSave={async (content) => {
const terminal = selectedTerminal;
if (!terminal || !selectedFile) return;
if (!terminal || !selectedFile || !selectedFileWritable) return;
const fileName = selectedFile.split('/').pop() ?? 'file';
const dir = selectedFile.substring(0, selectedFile.lastIndexOf('/') + 1) || '/';
const file = new File([content], fileName, { type: 'text/plain' });
@@ -1490,6 +1510,7 @@
onSelect={handleSelect}
onLongPress={enterSelectionMode}
showDate={sortBy === 'date'}
parentWritable={currentWritable}
/>
{/each}
</ul>
@@ -7,6 +7,7 @@
export let count: number = 0;
export let hasFiles: boolean = false;
export let canDelete = true;
export let onDelete: () => void = () => {};
export let onDownload: () => void = () => {};
@@ -64,8 +65,9 @@
<Tooltip content={$i18n.t('Delete')}>
<button
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={onDelete}
disabled={!canDelete}
aria-label={$i18n.t('Delete')}
>
<GarbageBin className="size-3.5" />
@@ -32,6 +32,10 @@
export let onSelect: (entry: FileEntry, event: MouseEvent) => void = () => {};
export let onLongPress: () => void = () => {};
export let showDate: boolean = false;
export let parentWritable = true;
$: writable = entry.writable !== false;
$: canMutate = parentWritable && writable;
const formatRelativeTime = (epoch: number): string => {
const diff = Math.floor(Date.now() / 1000) - epoch;
@@ -143,6 +147,7 @@
role={entry.type === 'directory' ? 'button' : undefined}
on:dragover={(e) => {
if (entry.type !== 'directory') return;
if (!writable) return;
if (!e.dataTransfer?.types.includes('application/x-terminal-file-move')) return;
e.preventDefault();
e.stopPropagation();
@@ -155,6 +160,7 @@
}}
on:drop={(e) => {
if (entry.type !== 'directory') return;
if (!writable) return;
const raw = e.dataTransfer?.getData('application/x-terminal-file-move');
if (!raw) return;
e.preventDefault();
@@ -173,8 +179,12 @@
>
<button
class="flex-1 flex items-center gap-2 px-3 py-1.5 text-left min-w-0"
draggable={true}
draggable={canMutate}
on:dragstart={(e) => {
if (!canMutate) {
e.preventDefault();
return;
}
const filePath = `${currentPath}${entry.name}`;
// If dragging a selected item, drag all selected
if (selected && selectedPaths.size > 1) {
@@ -282,6 +292,9 @@
{entry.name}
</span>
{/if}
{#if !writable && !renaming}
<span class="text-[10px] text-gray-400 shrink-0">Read-only</span>
{/if}
{#if entry.type === 'file' && entry.size !== undefined && !renaming}
{#if showDate && entry.modified}
<span class="text-[10px] text-gray-400 shrink-0"
@@ -354,9 +367,11 @@
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[13px] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[13px] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition disabled:opacity-40 disabled:hover:bg-transparent"
disabled={!canMutate}
on:click={(e) => {
e.stopPropagation();
if (!canMutate) return;
startRename();
}}
>
@@ -366,9 +381,11 @@
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[13px] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[13px] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition disabled:opacity-40 disabled:hover:bg-transparent"
disabled={!canMutate}
on:click={(e) => {
e.stopPropagation();
if (!canMutate) return;
onDelete(`${currentPath}${entry.name}`, entry.name);
}}
>
@@ -14,6 +14,7 @@
export let breadcrumbs: { label: string; path: string }[] = [];
export let selectedFile: string | null = null;
export let loading = false;
export let writable = true;
export let onNavigate: (path: string) => void = () => {};
export let onRefresh: () => void = () => {};
@@ -114,6 +115,7 @@
: ''}"
on:click={() => onNavigate(crumb.path)}
on:dragover={(e) => {
if (!writable) return;
if (!e.dataTransfer?.types.includes('application/x-terminal-file-move')) return;
e.preventDefault();
e.stopPropagation();
@@ -123,6 +125,7 @@
if (dragOverCrumb === i) dragOverCrumb = null;
}}
on:drop={(e) => {
if (!writable) return;
const raw = e.dataTransfer?.getData('application/x-terminal-file-move');
if (!raw) return;
e.preventDefault();
@@ -145,6 +148,11 @@
</span>
{/if}
</div>
{#if !writable}
<span class="text-[10px] text-gray-400 dark:text-gray-500 shrink-0">
Read-only
</span>
{/if}
<Tooltip content={$i18n.t('Refresh')}>
<button
@@ -240,8 +248,9 @@
</Dropdown>
<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"
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 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={onNewFolder}
disabled={!writable}
aria-label={$i18n.t('New Folder')}
>
<NewFolderAlt className="size-3.5" />
@@ -249,8 +258,9 @@
</Tooltip>
<Tooltip content={$i18n.t('New File')}>
<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"
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 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={onNewFile}
disabled={!writable}
aria-label={$i18n.t('New File')}
>
<FilePlusAlt className="size-3.5" />
@@ -279,8 +289,9 @@
</Tooltip>
<Tooltip content={$i18n.t('Upload')}>
<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"
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 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => uploadInput?.click()}
disabled={!writable}
aria-label={$i18n.t('Upload')}
>
<svg
@@ -305,7 +316,7 @@
multiple
hidden
on:change={async () => {
if (!uploadInput?.files?.length) return;
if (!writable || !uploadInput?.files?.length) return;
onUploadFiles(Array.from(uploadInput.files));
uploadInput.value = '';
}}
@@ -41,6 +41,7 @@
export let onSheetChange: ((sheet: string) => void) | null = null;
export let overlay = false;
export let readOnly = false;
export let onSave: ((content: string) => Promise<void>) | null = null;
@@ -59,6 +60,7 @@
};
export const startEdit = async () => {
if (readOnly) return;
editContent = fileContent ?? '';
editing = true;
showRaw = true;
@@ -67,7 +69,7 @@
};
export const saveEdit = async () => {
if (!onSave) return;
if (!onSave || readOnly) return;
saving = true;
await onSave(editContent);
saving = false;
@@ -81,7 +83,7 @@
/** Save code file directly from CodeMirror */
export const saveCodeFile = async () => {
if (!onSave) return;
if (!onSave || readOnly) return;
saving = true;
const content = fileCodeEditorRef?.getValue() ?? '';
await onSave(content);
@@ -426,7 +428,7 @@
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
filePath={selectedFile}
{onSave}
onSave={readOnly ? null : onSave}
/>
</div>
{:else if isMarkdown && !showRaw}
@@ -439,7 +441,7 @@
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
filePath={selectedFile}
{onSave}
onSave={readOnly ? null : onSave}
/>
</div>
{:else if isCsv && !showRaw && csvRows.length > 0}
@@ -496,7 +498,7 @@
bind:this={fileCodeEditorRef}
value={fileContent ?? ''}
filePath={selectedFile}
{onSave}
onSave={readOnly ? null : onSave}
/>
</div>
{:else if isSvg && highlightedHtml && !showRaw}