From e183b459c99871ce2c5633b300d1e5ab5569b4c1 Mon Sep 17 00:00:00 2001 From: Viet Tran Date: Fri, 27 Mar 2026 09:28:42 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20SQLite=20desktop=20edition=20=E2=80=94?= =?UTF-8?q?=20full=20desktop=20app=20with=20team=20tasks=20(#505)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(store): add SQLite backend foundation with build-tag injection Add sqlitestore package (//go:build sqlite) as alternative to PostgreSQL: - pool.go: WAL mode, busy_timeout, 4 concurrent read connections - helpers.go: ? param helpers, JSON array storage, nullable/update utils - scope.go: tenant scope with ? placeholders (vs PG's $N) - schema.sql: 1296-line flattened DDL from 29 PG migrations (51 tables) - schema.go: embedded schema with transactional apply + version tracking - factory.go: NewSQLiteStores() stub (stores wired in Phase 2) Build-tag split for store initialization: - cmd/gateway_stores_pg.go (//go:build !sqlite) — default PG-only - cmd/gateway_stores_sqlite.go (//go:build sqlite) — runtime PG/SQLite switch - cmd/gateway_setup.go: extracted wireTracingAndCron() shared helper Config: GOCLAW_STORAGE_BACKEND + GOCLAW_SQLITE_PATH env vars. Security: goclaw.db added to DenyPaths (exec, read_file, filesystem tools). * feat(edition): add centralized edition package for feature tier limits New internal/edition/ package — single source of truth for all edition limits: - Edition struct with MaxAgents, MaxTeams, MaxChannels, KGEnabled, TeamFullMode, etc. - Standard (default, all features) and Lite (desktop, 5 agents, 1 team) presets - atomic.Pointer for thread-safe Current()/SetCurrent() Wiring: - cmd/gateway.go: GOCLAW_EDITION env override (lite/standard) at startup - cmd/gateway_stores_sqlite.go: auto-set Lite when backend=sqlite - /v1/edition HTTP endpoint for UI comparison modal (public, no auth) * feat(sqlitestore): implement Phase 2A core stores + sqliteonly build tag Implement 12 SQLite store backends (4200+ LOC) mirroring existing PG stores: - SessionStore, AgentStore, ProviderStore, TracingStore, SnapshotStore - ConfigSecretsStore, SystemConfigStore, TenantStore, HeartbeatStore - BuiltinToolStore, BuiltinToolTenantConfigStore, SkillTenantConfigStore All stores wired in factory.go. Remaining stores (Memory, Cron, Skills, Teams, etc.) left nil — gateway handles gracefully. Add sqliteonly build tag for PG-free desktop builds: - go build . → PG only - go build -tags sqlite . → PG + SQLite (runtime switch) - go build -tags sqliteonly . → SQLite only (no pgx dependency) Key SQLite adaptations: - ? placeholders (not $N), json_extract/json_each/json_array_length - DISTINCT ON → GROUP BY + Go dedup, ANY($1) → IN (?,?...) - rows.Err() checks on all scan loops, execMapUpdateWhereTenant in helpers - GetOrCreateUserProfile uses RowsAffected() instead of PG's xmax trick * feat(sqlitestore): implement Phase 2B feature stores Port 8 additional store backends to SQLite (19 new files, ~6000 LOC): Cron: cron.go, cron_crud.go, cron_exec.go, cron_scheduler.go - Job scheduling with cache, ListDue, MarkRunning, MarkComplete Skills: skills.go, skills_crud.go, skills_content.go, skills_grants.go - CRUD, grants, content management. LIKE search (no FTS/vector in Lite) MCP: mcp_servers.go, mcp_servers_access.go, mcp_user_credentials.go - Server CRUD, agent/user grants, encrypted credentials Channels: channel_instances.go, pairing.go, pending_messages.go, contacts.go - Channel management, device pairing, message queue, contact store Teams: teams.go, teams_tasks.go, teams_tasks_lifecycle.go, teams_tasks_activity.go - Team/task CRUD, lifecycle transitions, activity log, progress tracking - JSON array for members/blocked_by (replaces PG text[]) Remaining nil stores: Memory, AgentLinks, KnowledgeGraph, Activity, SecureCLI, APIKeys, ConfigPermissions — gateway handles gracefully. * fix(sqlitestore): fix critical arg ordering + data races in Phase 2B stores C1: ListTasks arg mismatch — limit+1 in userID slot, wrong results C2: SearchTasks missing duplicate userID arg for (? = '' OR t.user_id = ?) H3: cron_crud.go cacheLoaded written without mutex → use InvalidateCache() H4: cron_exec.go discarded ExecContext error → log warning H1: Add rows.Err() checks in cron_crud, cron_scheduler, cron_exec, pairing * feat(sqlitestore): implement Phase 2C — Memory, Activity, APIKeys, ConfigPermissions Complete remaining essential stores (6 new files, ~900 LOC): Memory: memory.go, memory_docs.go, memory_search.go - Document/chunk CRUD, LIKE-based search (no vector in Lite edition) - Embedding methods return empty results gracefully Activity: activity.go — simple activity logging APIKeys: api_keys.go — API key CRUD with SHA-256 hash lookup ConfigPermissions: config_permissions.go — permission rules with TTL cache 24/27 stores now wired. Only AgentLinks, KnowledgeGraph, SecureCLI remain nil (disabled in Lite edition by design). Total: 49 files, 11,435 LOC in internal/store/sqlitestore/ * fix(sqlitestore): fix variable shadow in GetDocument + handle chunk delete error C1: GetDocument scopeClause used := inside if/else blocks, shadowing outer err variable — query errors silently swallowed. Fixed by renaming to tcErr matching PG pattern. H1: IndexDocument chunk deletion ExecContext error was discarded, could cause duplicate chunks. Now returns error on failure. * feat(desktop): Phase 3 — Wails v2 desktop app shell with chat UI Desktop app (ui/desktop/) using Wails v2 + React 19 + Tailwind CSS 4: Go backend: - main.go: Wails entry point with embedded frontend assets - app.go: gateway embedding via goroutine, health check, Wails bindings - keyring.go: OS keyring secrets with file fallback - cmd/gateway_export.go: exports RunGateway() for desktop embedding React frontend: - WS v3 client: protocol handshake, exponential backoff, call queue - Chat system: Zustand stores, RAF-batched streaming, 10 event handlers - Components: MessageBubble, MarkdownRenderer (rehype-sanitize), CodeBlock, ToolCallBlock, ThinkingBlock, ActivityIndicator, InputBar - Layout: AppShell (2-column), Sidebar with agent/session list, TopBar - Onboarding wizard (5 steps): welcome, gateway, provider, agent, ready - Magic Blue theme (dark/light), Inter + JetBrains Mono typography Build: all 3 variants pass (PG, sqlite, sqliteonly) * chore: ignore Wails build artifacts (wailsjs, build, package.json.md5) * fix(desktop): correct onboarding provider list and agent creation API contract - ProviderStep: expand from 3 to 16 providers in 4 groups (Popular, Cloud, Local, Regional) with correct provider_type values and api_base defaults - AgentStep: fix API payload — use agent_key (slug), provider (name string), agent_type=predefined with description in other_config - use-agents: fix field mapping — agent_key, display_name from backend response - Clean up failed provider on verify error * fix(desktop): add missing providers — Bailian Coding, Z.ai Coding, Ollama Cloud * fix(desktop): match web dashboard brand colors + persist onboarding in store - Replace Magic Blue theme with web dashboard's warm blue OKLCH palette - Move onboarding state from localStorage to Zustand persist store - Add "Run Setup Wizard" option in TopBar settings menu to re-trigger onboarding - Fix light mode theme activation (explicit :root:not(.dark) overrides) * fix(desktop): apply dark theme before first paint + better error messages - Add class="dark" default on + inline script to read persisted theme before React hydrate (prevents light flash on dark mode) - Improve provider test error message for network failures * feat(desktop): add GoClaw logo from web UI to onboarding + topbar * fix(desktop): add Vite proxy for dev mode to avoid CORS gateway errors - Proxy /v1, /ws, /health to localhost:18790 in Vite dev server - Use relative URLs in dev mode (import.meta.env.DEV) so proxy handles CORS - Production build uses direct gateway URL from Wails binding * fix(desktop): GoClaw dock icon + fix duplicate provider slug on re-test - Convert goclaw-icon.svg to 1024x1024 PNG for macOS dock icon - Fix provider creation: handle existing slug by finding and updating - Track build/appicon.png in git (exclude only build/bin/) * fix(sqlitestore): UUID text/blob mismatch in scopeClause breaks all queries scopeClause passed uuid.UUID (16-byte array) to SQLite ? placeholder, but tenant_id column stores TEXT strings. SQLite compared BLOB vs TEXT → no match → all scoped queries returned empty results. Fix: pass scope.TenantID.String() to ensure TEXT comparison. Also includes: - macOS dock icon (.icns from GoClaw logo) - Onboarding auto-detect existing providers/agents - Debug logging for token + API URL * fix(desktop): CORS for dev mode + direct gateway URL - Wails dev server (port 34115) doesn't proxy API calls, causing 405 - Frontend now connects directly to gateway URL from Wails binding - Added GOCLAW_DESKTOP=1 env → enables CORS middleware on gateway - desktopCORS wraps mux with Access-Control-Allow-* headers + OPTIONS - Simplified provider test flow: list-then-create/update * fix(desktop): split onboarding into 6 steps matching web UI flow Web UI flow: create provider → select model + verify → create agent. Desktop was incorrectly trying to verify before creating provider. Changes: - ProviderStep: now only creates/saves provider (no verify) - NEW ModelVerifyStep: loads models from provider, test connection - AgentStep: receives pre-selected model, shows read-only model field - OnboardingWizard: 6 steps with auto-detect skip logic - Auto-detect: has agents→Ready, has providers→ModelVerify, nothing→Provider * feat(desktop): add Combobox component, use searchable model selector - New Combobox: searchable dropdown with custom value support - ModelVerifyStep: replace plain input/select with Combobox - Models loaded from GET /v1/providers/{id}/models API - Allows typing custom model name if API returns empty list * fix(desktop): rename icon to iconfile.icns matching Wails convention * refactor(desktop): overhaul UI/UX, fix onboarding, fix event handling Desktop app major refactor: UI/UX: - Chat-focused layout with floating panels on dotted canvas - Sidebar: agent list + sessions grouped by date (no resource counts) - Modern input bar: rounded pill with attach/send buttons - User bubble: card style matching web UI (not solid color) - Thinking block: collapsible, max-height, proper label - ErrorBoundary wrapping app - Dock icon: regenerated with transparent bg + dark rounded frame Onboarding: - 3-step flow matching web UI (Provider → Model → Agent) - Proper create vs update (check DB before POST) - SetupStepper with step circles + connectors - Agent presets from web UI (Fox Spirit, Artisan, Astrologer) - Auto-detect existing setup via use-bootstrap-status hook Event handling (verified from Go source): - chunk: payload.content (not payload.chunk) - thinking: payload.content (not payload.thinking) - tool.call: payload.id/name (not toolId/toolName) - tool.result: payload.is_error/content (not error field) - run.completed: usage.prompt_tokens/completion_tokens - New: block.reply, run.retrying handlers Backend fixes: - SQLite scanTime helper for modernc.org/sqlite text timestamps - X-GoClaw-User-Id header in desktop API client (not X-User-ID) - user_id: system (owner role in desktop single-user mode) - CORS: allow X-GoClaw-User-Id header - SQLite busy timeout: 5s → 10s - Snapshot SQL: cross-DB compatible (FILTER→CASE, ::BIGINT→CAST) - Promise.allSettled for bootstrap status (one fail doesn't block other) * feat(desktop): chat polish, SQLite fixes, summoning modal, bootstrap guard Chat Polish (Phase 1): - ToolCallBlock: Wrench/Zap icons, phase badges, arg summary, grouped rendering - ThinkingBlock: auto-expand on stream, Brain icon, cursor pulse - MessageBubble: isStreaming prop, streaming cursor, grouped tool calls - ImageLightbox: fullscreen overlay, gallery nav, keyboard shortcuts, download - MediaBlock: grid layout, click-to-open lightbox, hover overlay - ActivityIndicator: phase-specific icons (Brain/Wrench/RefreshCw) - ChatCanvas: track lastAssistantId for streaming, EmptyState with prompts - Filter [System] nudge messages and tool-role messages from chat history SQLite Fixes: - sessions_list: fix time.Time scan failure (3 sites) using sqliteTime scanner - snapshots: fix ON CONFLICT expression mismatch with unique index - snapshots: fix GetLatestBucket using nullSqliteTime - pool: explicit PRAGMAs (busy_timeout=15s, WAL, synchronous=NORMAL) - schema: seed master tenant (was missing, causing FK violations) - schema: incremental migration framework (version-gated patches) Onboarding: - SummoningModal: port from web UI with framer-motion animations - AgentStep: show summoning modal after create, continue button - App: auto-detect empty DB and reset onboarded flag - ChatCanvas: loading spinner while agent loads after onboarding Bootstrap Guard: - After auto-cleanup of BOOTSTRAP.md, check if USER.md is still empty - Inject reminder if agent cleared BOOTSTRAP but didn't fill USER.md Session Management: - Load chat history on session click (was missing useEffect) - Fix race condition: don't clear messages on session switch (atomic replace) - SidebarFooter: center New Chat button text * fix(desktop): session management, delete confirm, event listener race fix - New Chat: only clears state, no empty session creation (sendMessage auto-creates) - Delete session: hover X button with ConfirmDialog confirmation - Event listener: use sessionKeyRef instead of closure to prevent stale events - Remove "skip to dashboard" link from onboarding - Add reusable ConfirmDialog + ConfirmDeleteDialog common components * feat(desktop): settings view with tabbed layout (Phase 4) - SettingsView: tab container with header, close button, canvas-dots bg - SettingsTabBar: 9 tabs (Appearance, Providers, Agents, MCP, Skills, Tools, Cron, Traces, About) - AppearanceTab: dark/light theme toggle, language + timezone placeholders - AboutTab: version, edition limits, runtime info - ui-store: activeView, settingsTab, openSettings(), closeSettings() - AppShell: switch between chat and settings views - SidebarFooter: gear icon opens settings (was "Run Setup Wizard") - Keyboard: Cmd+, opens settings, Escape closes - Branding: "GoClaw Lite" in sidebar header - Agent status: online (green) instead of idle for desktop - Tab content wrapped in solid bg card with border * feat(desktop): provider management CRUD in settings (Phase 5) - use-providers hook: list, create, update, delete, verify via HTTP API - ProviderList: list view with Add button, empty state - ProviderRow: status dot, type badge, edit/delete actions - ProviderFormDialog: create/edit with type selector, masked API key, test connection - Wire ProviderList into Settings Providers tab * fix(desktop): remove Test Connection from provider form (requires model + provider ID) * feat(desktop): agent management CRUD in settings (Phase 6) - AgentData/AgentInput types matching web UI contracts - use-agent-crud hook: list, create, update, delete, resummon (5 agent limit) - AgentCard: emoji, status dot, provider/model/type badges, edit/delete/resummon - AgentFormDialog: provider Combobox + model Combobox (from /v1/providers/{id}/models) - Create: agent_type selector, "Check & Create" verifies model before create - Edit: no type change, no re-verify needed - Personality textarea for predefined agents - AgentList: grid, edition limit warning, create triggers SummoningModal - Delete uses ConfirmDeleteDialog (type name to confirm) - Sidebar agent list refreshes after CRUD * feat(desktop): agent detail panel with full config (Phase 6 polish) Agent detail panel (fullscreen overlay covering sidebar): - PersonalitySection: emoji editor, display name, description, status select, default toggle, agent key display - ModelBudgetSection: provider/model Combobox with verify-before-save, context window, max tool iterations - EvolutionSection: self_evolve toggle with info callout (predefined agents only) - Sticky save bar: backdrop blur, cancel/save buttons, spinner on save - Save blocked if provider/model changed but not verified AgentList: card click opens detail panel, create dialog separate AgentFormDialog: create-only, Check & Create with verify * fix(desktop): resummon confirm, summoning z-index, save bar UX - Resummon requires confirm dialog before triggering - SummoningModal z-index z-50 → z-[70] (above detail panel z-[60]) - Save bar: show error inline, "Verify model first" when blocked * feat(desktop): agent detail quality polish — files tab, memory config, rich cards Types (synced with web UI): - AgentData: added owner_id, workspace, restrict_to_workspace, frontmatter, context_window, max_tool_iterations as required fields - MemoryConfig, CompactionConfig interfaces - BootstrapFile type for WS file operations AgentCard (matching web UI agent-card.tsx): - Star icon for default agent - Animated pulse badge for summoning status - Self-evolve sparkle indicator (orange when active) - Frontmatter/expertise with line-clamp-3 - Context window display (e.g. "200K ctx") - Safe emoji extraction, UUID name detection AgentDetailPanel: - Tab navigation: Overview + Files tabs - Overview: Personality + ModelBudget + Memory + Evolution sections - Files tab: WS-based file editor (agents.files.list/get/set) - File sidebar with selection - Textarea editor with dirty tracking - Save button with spinner - Sticky save bar with backdrop blur (overview only) - Resummon with confirm dialog MemorySection (matching web UI memory-section.tsx): - Enable/disable toggle - 6 config fields: max_results, min_score, max_chunk_len, chunk_overlap, vector_weight, text_weight - "Using global defaults" when disabled * feat(desktop): agent files — hide USER/HEARTBEAT, add Edit with AI - Hide USER.md, USER_PREDEFINED.md, HEARTBEAT.md from files tab (managed by bootstrap/cron) - Add "Edit with AI" button → RegenerateDialog → POST /v1/agents/{id}/regenerate - Auto-select first file on load - Show file size in sidebar - Pass agentId to files tab for regenerate API call * fix(desktop): remove memory config section (no embedding in SQLite) * fix(desktop): remove misleading bytes display from file sidebar * fix(desktop): replace native checkboxes/selects with custom Switch + Combobox - New Switch component matching Radix UI toggle style - Replace all native with Switch in agent/provider forms - Replace native setSearch(e.target.value)} + placeholder="Search timezone..." + className="w-full px-2 py-1 text-xs bg-surface-tertiary border border-border rounded text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent" + autoFocus + /> + +
+ {filtered.slice(0, 50).map((tz) => ( + + ))} +
+ + )} + + ) +} + +export function ChatTopBar() { + const { selectedAgent } = useAgents() + const toggleSidebar = useUiStore((s) => s.toggleSidebar) + const sidebarOpen = useUiStore((s) => s.sidebarOpen) + + return ( +
+ {/* Sidebar toggle */} + {!sidebarOpen && ( + + )} + + {/* Agent info */} +
+ {selectedAgent ? ( +
+ {selectedAgent.name} + + {selectedAgent.model} + +
+ ) : ( + Select an agent to start chatting + )} +
+ + {/* Top right pickers */} +
+ + +
+
+ ) +} diff --git a/ui/desktop/frontend/src/components/chat/CodeBlock.tsx b/ui/desktop/frontend/src/components/chat/CodeBlock.tsx new file mode 100644 index 00000000..8a5942cf --- /dev/null +++ b/ui/desktop/frontend/src/components/chat/CodeBlock.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' +import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism' + +interface CodeBlockProps { + language: string + code: string +} + +export function CodeBlock({ language, code }: CodeBlockProps) { + const { t } = useTranslation('common') + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( +
+
+ {language} + +
+ + {code} + +
+ ) +} diff --git a/ui/desktop/frontend/src/components/chat/FileButton.tsx b/ui/desktop/frontend/src/components/chat/FileButton.tsx new file mode 100644 index 00000000..dfaa7c49 --- /dev/null +++ b/ui/desktop/frontend/src/components/chat/FileButton.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { FilePreviewDialog } from './FilePreviewDialog' +import { downloadFile } from './AuthImage' + +export interface FileButtonProps { + url: string + filename: string + mimeType?: string + size?: number +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +function fileIcon(name: string): string { + if (/\.(md|markdown)$/i.test(name)) return '📝' + if (/\.(ts|tsx|js|jsx|py|go|rs|java|c|cpp|h|rb|sh|yaml|yml|toml)$/i.test(name)) return '💻' + if (/\.(zip|tar|gz|rar|7z)$/i.test(name)) return '📦' + if (/\.(csv|xls|xlsx)$/i.test(name)) return '📊' + if (/\.(pdf)$/i.test(name)) return '📕' + if (/\.(json|xml|html|css)$/i.test(name)) return '📋' + return '📄' +} + +function truncateName(name: string, max = 30): string { + if (name.length <= max) return name + const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '' + return name.slice(0, max - ext.length - 1) + '…' + ext +} + +export function FileButton({ url, filename, mimeType, size }: FileButtonProps) { + const { t } = useTranslation('common') + const [previewOpen, setPreviewOpen] = useState(false) + + // Strip timestamp from filename for display: "file.1774537056.md" → "file.md" + const displayName = filename.split('?')[0].replace(/\.\d{9,}(\.\w+)$/, '$1') + + return ( + <> + + + + + + {previewOpen && createPortal( + setPreviewOpen(false)} + />, + document.body, + )} + + ) +} diff --git a/ui/desktop/frontend/src/components/chat/FilePreviewDialog.tsx b/ui/desktop/frontend/src/components/chat/FilePreviewDialog.tsx new file mode 100644 index 00000000..54145820 --- /dev/null +++ b/ui/desktop/frontend/src/components/chat/FilePreviewDialog.tsx @@ -0,0 +1,225 @@ +import { useEffect, useState, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' +import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism' +import { MarkdownRenderer } from './MarkdownRenderer' +import { AuthImage, downloadFile } from './AuthImage' +import { getApiClient, isApiClientReady } from '../../lib/api' + +// Strip ?ft= token and timestamps from filename for display +function cleanFilename(name: string): string { + // Remove query params + const base = name.split('?')[0] + // Remove timestamp suffix: "file.1774537056.md" → "file.md" + return base.replace(/\.\d{9,}(\.\w+)$/, '$1') +} + +interface FilePreviewDialogProps { + url: string + filename: string + mimeType?: string + onClose: () => void +} + +function getLanguage(filename: string): string { + const ext = filename.split('.').pop()?.toLowerCase() ?? '' + const map: Record = { + ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx', + py: 'python', go: 'go', rs: 'rust', java: 'java', + c: 'c', cpp: 'cpp', h: 'c', rb: 'ruby', sh: 'bash', + yaml: 'yaml', yml: 'yaml', toml: 'toml', json: 'json', + xml: 'xml', html: 'html', css: 'css', + } + return map[ext] ?? 'text' +} + +function isImage(filename: string, mimeType?: string): boolean { + if (mimeType?.startsWith('image/')) return true + return /\.(jpe?g|png|gif|webp|svg|bmp|ico)$/i.test(filename) +} + +function isVideo(filename: string, mimeType?: string): boolean { + if (mimeType?.startsWith('video/')) return true + return /\.(mp4|webm|ogg|mov)$/i.test(filename) +} + +function isAudio(filename: string, mimeType?: string): boolean { + if (mimeType?.startsWith('audio/')) return true + return /\.(mp3|wav|ogg|flac|aac|m4a)$/i.test(filename) +} + +function isMarkdown(filename: string): boolean { + return /\.(md|markdown)$/i.test(filename) +} + +function isCode(filename: string): boolean { + return /\.(ts|tsx|js|jsx|py|go|rs|java|c|cpp|h|rb|sh|yaml|yml|toml|json|xml|html|css)$/i.test(filename) +} + +function isText(filename: string, mimeType?: string): boolean { + if (mimeType?.startsWith('text/')) return true + return /\.(txt|log|csv|conf|ini|env)$/i.test(filename) +} + +export function FilePreviewDialog({ url, filename: rawFilename, mimeType, onClose }: FilePreviewDialogProps) { + const { t } = useTranslation('common') + const [textContent, setTextContent] = useState(null) + const [loadError, setLoadError] = useState(false) + + // Normalize: if filename is a full URL/path, extract the basename + const filename = (rawFilename.includes('/') ? rawFilename.split('/').pop() : rawFilename)?.split('?')[0] ?? rawFilename + const needsTextFetch = isMarkdown(filename) || isCode(filename) || isText(filename, mimeType) + + useEffect(() => { + if (!needsTextFetch) return + let cancelled = false + setTextContent(null) + setLoadError(false) + // For URLs without ?ft= token, sign them first to get a valid ft= token. + // This avoids tenant header issues with direct Bearer auth fetch. + // Fetch with Bearer auth via ApiClient.fetchFile (handles base URL + auth header). + const doFetch = isApiClientReady() + ? getApiClient().fetchFile(url) + : fetch(url) + doFetch + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`) + return r.text() + }) + .then((text) => { if (!cancelled) setTextContent(text) }) + .catch(() => { if (!cancelled) setLoadError(true) }) + return () => { cancelled = true } + }, [url, needsTextFetch]) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, + [onClose], + ) + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [handleKeyDown]) + + const displayName = cleanFilename(filename) + + function renderContent() { + if (isImage(filename, mimeType)) { + return ( + + ) + } + if (isVideo(filename, mimeType)) { + return