mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-28 05:27:35 +00:00
refac
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true
USE_CUDA_VER=cu126
free_disk:true name:cuda126 suffix:-cuda126]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true
USE_CUDA_VER=cu126
free_disk:true name:cuda126 suffix:-cuda126]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Waiting to run
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Waiting to run
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
Some checks are pending
Python CI / Ruff Format (3.11) (push) Waiting to run
Python CI / Ruff Format (3.12) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true
USE_CUDA_VER=cu126
free_disk:true name:cuda126 suffix:-cuda126]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true
USE_CUDA_VER=cu126
free_disk:true name:cuda126 suffix:-cuda126]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Waiting to run
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Waiting to run
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Blocked by required conditions
Create and publish Docker images with specific build args / notify-helm-charts (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Blocked by required conditions
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Blocked by required conditions
Frontend Build / Format & Build (push) Waiting to run
Frontend Build / Unit Tests (push) Waiting to run
This commit is contained in:
parent
5caa91a493
commit
2dadc5435a
7 changed files with 101 additions and 38 deletions
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue