refac
This commit is contained in:
@@ -23,6 +23,7 @@ from open_webui.utils.json_codec import JSONCodec
|
||||
from open_webui.utils.terminals import (
|
||||
TERMINAL_CONTEXT_HEADER,
|
||||
get_terminal_server_url,
|
||||
is_terminal_orchestrator,
|
||||
terminal_context_available,
|
||||
terminal_context_config,
|
||||
terminal_context_id,
|
||||
@@ -233,7 +234,7 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
|
||||
The client must send ``{"type": "auth", "token": "<jwt>"}`` as its first
|
||||
message after connecting.
|
||||
|
||||
Returns ``(user, connection, chat_id)`` on success, or ``None`` after
|
||||
Returns ``(user, connection, chat_id, token)`` on success, or ``None`` after
|
||||
closing *ws* with an appropriate error code.
|
||||
"""
|
||||
import asyncio
|
||||
@@ -247,7 +248,8 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
|
||||
if payload.get('type') != 'auth':
|
||||
await ws.close(code=4001, reason='Expected auth message')
|
||||
return None
|
||||
user = await get_verified_user_by_token(payload.get('token', ''), getattr(ws.app.state, 'redis', None))
|
||||
token = payload.get('token', '')
|
||||
user = await get_verified_user_by_token(token, getattr(ws.app.state, 'redis', None))
|
||||
if user is None:
|
||||
await ws.close(code=4001, reason='Invalid token')
|
||||
return None
|
||||
@@ -279,7 +281,7 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
|
||||
if not terminal_context_available(connection, 'chat'):
|
||||
await ws.close(code=4003, reason='Terminal server is not available in chats')
|
||||
return None
|
||||
return user, connection, chat_id if isinstance(chat_id, str) else ''
|
||||
return user, connection, chat_id if isinstance(chat_id, str) else '', token
|
||||
|
||||
|
||||
@router.websocket('/{server_id}/api/terminals/{session_id}')
|
||||
@@ -292,14 +294,14 @@ async def ws_terminal(
|
||||
|
||||
Uses first-message auth: the client sends ``{"type": "auth", "token": "<jwt>"}``
|
||||
as its first message. The proxy validates the JWT, then connects to the
|
||||
upstream terminal server and authenticates with the server's API key.
|
||||
upstream terminal server using the configured terminal auth mode.
|
||||
"""
|
||||
await ws.accept()
|
||||
|
||||
result = await _resolve_authenticated_connection(ws, server_id)
|
||||
if result is None:
|
||||
return
|
||||
user, connection, chat_id = result
|
||||
user, connection, chat_id, token = result
|
||||
|
||||
base_url = get_terminal_server_url(connection)
|
||||
if not base_url:
|
||||
@@ -347,6 +349,8 @@ async def ws_terminal(
|
||||
if auth_type == 'bearer':
|
||||
key = normalize_bearer_token(connection.get('key', ''))
|
||||
await upstream.send_str(_json.dumps({'type': 'auth', 'token': key}))
|
||||
elif auth_type == 'session' and is_terminal_orchestrator(connection):
|
||||
await upstream.send_str(_json.dumps({'type': 'auth', 'token': token}))
|
||||
|
||||
await publish_event(
|
||||
app,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user