From 64089375647cf7a3d95c31a1171e0cbecbac3cc6 Mon Sep 17 00:00:00 2001 From: elihahah666 Date: Thu, 4 Jun 2026 13:48:57 +0800 Subject: [PATCH] feat(web): redesign Design Files panel + pick working directory from Home (#3358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): align Design Files panel with Claude reference UI Redesign the Design Files panel into a continuous grouped table that mirrors Claude's own Design Files surface: uppercase gray section bands (Folders / Stylesheets / Scripts / Documents / Images …), flush full-width rows with a colored icon block + bold name + type subtitle + right-aligned timestamp, a clean single-line toolbar (breadcrumbs left, New sketch / Paste / Upload right), a right preview card with a centered Open action and `Modified … · size · EXT` stats, and a static USEFUL INFO footer. Removes the old table/sort-header/kind-filter/pagination/group-toggle chrome and the conflicting legacy `.app .df-*` overrides in routines.css that were trumping the panel's own stylesheet via specificity. Preserves batch select/download/delete, rename, the row context menu, live-artifacts, plugin-folders, drag-upload, and the reloading overlay. Adds the designFiles.* section/kind/useful-info i18n keys across all locales. * feat: pick a working directory from Home before the project exists Let users choose a code folder on the Home screen up front, instead of only being able to replace it after a project is created. The HomeHero composer gains a folder pill (pick / clear); the chosen path is threaded through project creation and applied via the working-dir API once the project id exists. On desktop the working-dir POST is gated behind a host-minted token, so add a `dialog:pick-working-dir` IPC handler that shows the native picker and mints a single-use token bound to the folder, plus a `pickHostWorkingDir()` bridge + types/normalizer in `@open-design/host`. The renderer feature-detects the bridge and falls back to the web folder dialog (no token) otherwise. Native dialogs now allow creating a new folder inline (`createDirectory` / `ShowNewFolderButton`), and macOS uses `choose folder` so the panel reliably takes key focus. Wire the WorkingDirPill into the project composer footer and surface a reloading state + breadcrumb root label to the Design Files panel while a replace reindexes. Drop the WorkingDirPill "recent directories" localStorage list in favor of a single Replace action. * fix(web): preserve staged Home attachments when picking a working dir Move the working-dir handoff ahead of the initial upload so staged attachments land in the external folder instead of the temporary managed root, and fall back to the browser folder dialog when the desktop host build does not expose pickWorkingDir. * fix(web): surface host pick errors and normalize Windows dir basenames When the desktop host is present but cannot mint a working-dir token, surface the error instead of falling back to the browser folder dialog — the tokenless raw path would be rejected by the desktop auth gate as a late create-time failure. Only the pure web path (no token gate) keeps the dialog fallback. Also split the Home pill basename on both path separators so Windows paths show the folder name, not the full path. * fix(web,desktop): harden Home working-dir handoff against auth/token failures Run the desktop-auth handshake before minting the Home working-dir token so a missed startup registration fails up front instead of as a rejected deferred POST. Surface a create-time error toast when the working-dir handoff fails (expired ~60s token or daemon rejection) instead of silently leaving the project in the managed root while the user believes their folder was applied. * fix(web): abort create on failed working-dir handoff and always show root crumb When the pre-create working-dir handoff fails, short-circuit the rest of the Home submit path (skip the staged-attachment upload and auto-send) so the first run cannot proceed against the managed `.od/projects/` tree the user did not choose, and reword the error toast accordingly. Always render the Design Files root breadcrumb, falling back to the `designFiles.crumbs` label when no rootDirName exists, so the toolbar is not blank on the default managed-storage view. Adds regressions for the short-circuit and default-root crumb cases. Generated-By: looper 0.8.1 (runner=fixer, agent=claude-code) * fix(web): normalize Windows separators in WorkingDirPill basename shortPath() split only on '/', so a Windows working dir like C:\work\repo rendered as the full absolute path in the footer pill instead of the folder name. Split on both separators to match HomeHero/ProjectView, and add a regression covering the backslash path. Generated-By: looper 0.8.1 (runner=fixer, agent=claude-code) --------- Co-authored-by: qiongyu1999 <2694684348@qq.com> --- apps/daemon/src/native-folder-dialog.ts | 2 +- apps/daemon/src/server.ts | 6 + .../daemon/tests/native-folder-dialog.test.ts | 2 +- apps/desktop/src/main/preload.cts | 26 + apps/desktop/src/main/runtime.ts | 81 +- .../main/mint-home-working-dir-token.test.ts | 77 + apps/web/src/App.tsx | 52 +- apps/web/src/components/DesignFilesPanel.tsx | 1984 +++----------- apps/web/src/components/EntryShell.tsx | 1 + apps/web/src/components/FileWorkspace.tsx | 28 +- apps/web/src/components/HomeHero.tsx | 31 + apps/web/src/components/HomeView.tsx | 50 +- apps/web/src/components/PluginLoopHome.tsx | 5 + apps/web/src/components/ProjectView.tsx | 33 + apps/web/src/components/WorkingDirPill.tsx | 59 +- apps/web/src/i18n/locales/ar.ts | 9 +- apps/web/src/i18n/locales/de.ts | 9 +- apps/web/src/i18n/locales/en.ts | 9 +- apps/web/src/i18n/locales/es-ES.ts | 9 +- apps/web/src/i18n/locales/fa.ts | 9 +- apps/web/src/i18n/locales/fr.ts | 9 +- apps/web/src/i18n/locales/hu.ts | 9 +- apps/web/src/i18n/locales/id.ts | 9 +- apps/web/src/i18n/locales/it.ts | 9 +- apps/web/src/i18n/locales/ja.ts | 9 +- apps/web/src/i18n/locales/ko.ts | 9 +- apps/web/src/i18n/locales/pl.ts | 9 +- apps/web/src/i18n/locales/pt-BR.ts | 9 +- apps/web/src/i18n/locales/ru.ts | 9 +- apps/web/src/i18n/locales/th.ts | 9 +- apps/web/src/i18n/locales/tr.ts | 9 +- apps/web/src/i18n/locales/uk.ts | 9 +- apps/web/src/i18n/locales/zh-CN.ts | 9 +- apps/web/src/i18n/locales/zh-TW.ts | 9 +- apps/web/src/i18n/types.ts | 7 + apps/web/src/styles/home/home-hero.css | 17 +- apps/web/src/styles/viewer/routines.css | 240 -- .../web/src/styles/workspace/design-files.css | 2413 ++--------------- .../App.project-create-race.test.tsx | 80 + ...signFilesPanel.long-name-truncate.test.tsx | 14 +- .../components/DesignFilesPanel.test.tsx | 715 +---- ...signFilesPanel.view-state-persist.test.tsx | 191 -- .../components/HomeView.working-dir.test.tsx | 108 + .../tests/components/WorkingDirPill.test.tsx | 12 + packages/host/src/index.ts | 61 + 45 files changed, 1497 insertions(+), 4969 deletions(-) create mode 100644 apps/desktop/tests/main/mint-home-working-dir-token.test.ts delete mode 100644 apps/web/tests/components/DesignFilesPanel.view-state-persist.test.tsx create mode 100644 apps/web/tests/components/HomeView.working-dir.test.tsx diff --git a/apps/daemon/src/native-folder-dialog.ts b/apps/daemon/src/native-folder-dialog.ts index b9b8c8a68d..c3fcfaafd6 100644 --- a/apps/daemon/src/native-folder-dialog.ts +++ b/apps/daemon/src/native-folder-dialog.ts @@ -14,7 +14,7 @@ const WINDOWS_FOLDER_DIALOG_SCRIPT = [ '$owner.Height = 1;', '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog;', "$dialog.Description = 'Select a code folder to link';", - '$dialog.ShowNewFolderButton = $false;', + '$dialog.ShowNewFolderButton = $true;', 'try {', ' if ($dialog.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) { $dialog.SelectedPath }', '} finally {', diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 42dfcb1519..4735af38a5 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -3615,6 +3615,12 @@ function openNativeFolderDialog() { return new Promise((resolve) => { const platform = process.platform; if (platform === 'darwin') { + // `choose folder` is handled specially by the system: it presents a fully + // interactive standard navigation panel that reliably takes key focus + // (unlike a JXA-driven NSOpenPanel from background-only osascript, which + // renders but can't be clicked). That standard panel already includes a + // "New Folder" button in the bottom-left, so users can create a folder + // inline without any extra wiring. execFile( 'osascript', ['-e', 'POSIX path of (choose folder with prompt "Select a code folder to link")'], diff --git a/apps/daemon/tests/native-folder-dialog.test.ts b/apps/daemon/tests/native-folder-dialog.test.ts index 5d54735eb9..e2c83f9416 100644 --- a/apps/daemon/tests/native-folder-dialog.test.ts +++ b/apps/daemon/tests/native-folder-dialog.test.ts @@ -27,7 +27,7 @@ describe('native folder dialog helpers', () => { const script = buildWindowsFolderDialogCommand().args[3] ?? ''; expect(script).toContain('$dialog = New-Object System.Windows.Forms.FolderBrowserDialog;'); - expect(script).toContain('$dialog.ShowNewFolderButton = $false;'); + expect(script).toContain('$dialog.ShowNewFolderButton = $true;'); expect(script).toContain('$dialog.ShowDialog($owner)'); expect(script).toContain('$owner.Dispose();'); }); diff --git a/apps/desktop/src/main/preload.cts b/apps/desktop/src/main/preload.cts index a06f5468a9..0bf5b6fcbd 100644 --- a/apps/desktop/src/main/preload.cts +++ b/apps/desktop/src/main/preload.cts @@ -9,6 +9,7 @@ import type { OpenDesignHostFailure, OpenDesignHostProjectImportResult, OpenDesignHostProjectReplaceWorkingDirResult, + OpenDesignHostPickWorkingDirResult, OpenDesignHostUpdaterActionOptions, OpenDesignHostUpdaterStatusListener, OpenDesignHostUpdaterStatusSnapshot, @@ -94,6 +95,27 @@ function normalizeProjectReplaceWorkingDirResult(input: unknown): OpenDesignHost return { baseDir, entryFile, ok: true }; } +function pickWorkingDirFailure(reason: string): OpenDesignHostPickWorkingDirResult { + return failure(reason); +} + +function normalizePickWorkingDirResult(input: unknown): OpenDesignHostPickWorkingDirResult { + if (!isRecord(input)) return failure('desktop working-dir pick returned an invalid response', input); + if (input.ok !== true) { + if (input.canceled === true) return { canceled: true, ok: false }; + return failure( + typeof input.reason === 'string' && input.reason.length > 0 ? input.reason : 'unknown failure', + input.details, + ); + } + const baseDir = typeof input.baseDir === 'string' ? input.baseDir : null; + const token = typeof input.token === 'string' ? input.token : null; + if (baseDir == null || token == null) { + return failure('desktop working-dir pick did not include baseDir and token', input); + } + return { baseDir, ok: true, token }; +} + function normalizeProjectImportResult(input: unknown): OpenDesignHostProjectImportResult { if (!isRecord(input)) return failure('desktop import returned an invalid response', input); if (input.ok !== true) { @@ -159,6 +181,10 @@ const project = { ipcRenderer.invoke('dialog:pick-and-replace-working-dir', { projectId }) .then(normalizeProjectReplaceWorkingDirResult) .catch((error: unknown) => replaceWorkingDirFailure(reasonFromError(error))), + pickWorkingDir: (): Promise => + ipcRenderer.invoke('dialog:pick-working-dir') + .then(normalizePickWorkingDirResult) + .catch((error: unknown) => pickWorkingDirFailure(reasonFromError(error))), }; const shell = { diff --git a/apps/desktop/src/main/runtime.ts b/apps/desktop/src/main/runtime.ts index 9f6aa40c0b..62b090037b 100644 --- a/apps/desktop/src/main/runtime.ts +++ b/apps/desktop/src/main/runtime.ts @@ -577,6 +577,62 @@ export async function pickAndReplaceWorkingDir( return { ok: true, response: body }; } +/** + * Pure helper for the `dialog:pick-working-dir` IPC handler (the Home, + * pre-create flow). Unlike `pickAndImportFolder` / `pickAndReplaceWorkingDir`, + * there is no project yet, so we cannot POST to the daemon to discover a + * `503 DESKTOP_AUTH_PENDING` and self-heal. The token we mint here is spent + * LATER, by the renderer, on `POST /api/projects/:id/working-dir` once the + * project exists. + * + * If the daemon missed its startup auth-registration window, that deferred + * POST is guaranteed to be rejected with `DESKTOP_AUTH_PENDING` — and the + * renderer's create flow surfaces that as a confusing late failure. To keep + * the Home picker on par with the import/replace flows' self-healing, we + * proactively run the desktop-auth handshake (`registerDesktopAuth`) BEFORE + * minting and returning the token, so the daemon already knows the secret by + * the time the renderer spends the token. + * + * Extracted from `createDesktopRuntime` so vitest can pin the + * DESKTOP_AUTH_PENDING re-registration path without booting Electron. + */ +export type MintHomeWorkingDirTokenDeps = { + baseDir: string; + desktopAuthSecret: Buffer; + /** Lazy desktop-auth handshake. Mirrors the import/replace flows. */ + registerDesktopAuth?: () => Promise; + /** Injected for tests; defaults to the production HMAC mint. */ + mintToken?: (secret: Buffer, baseDir: string) => string; +}; + +export type MintHomeWorkingDirTokenResult = + | { baseDir: string; ok: true; token: string } + | { ok: false; reason: string }; + +export async function mintHomeWorkingDirToken( + deps: MintHomeWorkingDirTokenDeps, +): Promise { + const mint = deps.mintToken ?? mintImportToken; + const baseDir = deps.baseDir.trim(); + if (baseDir.length === 0) { + return { ok: false, reason: "picker returned an empty path" }; + } + // Ensure the daemon has the desktop-auth secret registered before we hand + // the renderer a token bound to it. A failed handshake here means the + // deferred working-dir POST would fail anyway, so report it now while the + // user is still in the picker rather than as a silent late create failure. + if (deps.registerDesktopAuth != null) { + const registered = await deps.registerDesktopAuth(); + if (!registered) { + return { + ok: false, + reason: "desktop auth handshake with the daemon failed; please retry", + }; + } + } + return { baseDir, ok: true, token: mint(deps.desktopAuthSecret, baseDir) }; +} + const MAC_WINDOW_CHROME = process.platform === "darwin" ? ({ @@ -1161,6 +1217,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom ipcMain.removeHandler("dialog:pick-folder"); ipcMain.removeHandler("dialog:pick-and-import"); ipcMain.removeHandler("dialog:pick-and-replace-working-dir"); + ipcMain.removeHandler("dialog:pick-working-dir"); ipcMain.removeHandler("shell:open-external"); ipcMain.removeHandler("shell:open-path"); ipcMain.removeHandler("browser:clear-data"); @@ -1215,7 +1272,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom if (!apiBaseUrl) { return { ok: false, reason: "daemon API URL not available" }; } - const result = await dialog.showOpenDialog({ properties: ["openDirectory"] }); + const result = await dialog.showOpenDialog({ properties: ["openDirectory", "createDirectory"] }); if (result.canceled || result.filePaths.length === 0) { return { ok: false, canceled: true }; } @@ -1258,7 +1315,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom if (!apiBaseUrl) { return { ok: false, reason: "daemon API URL not available" }; } - const result = await dialog.showOpenDialog({ properties: ["openDirectory"] }); + const result = await dialog.showOpenDialog({ properties: ["openDirectory", "createDirectory"] }); if (result.canceled || result.filePaths.length === 0) { return { ok: false, canceled: true }; } @@ -1275,6 +1332,26 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom }); }, ); + // Home-flow counterpart: the project does not exist yet, so we only show + // the native picker and mint a token bound to the chosen folder. The + // renderer threads { baseDir, token } back through project creation and + // spends the token on POST /api/projects/:id/working-dir once the project + // exists. Main remains the single source of filesystem paths crossing into + // the daemon (same trust boundary as dialog:pick-and-replace-working-dir). + ipcMain.handle("dialog:pick-working-dir", async () => { + if (options.desktopAuthSecret == null) { + return { ok: false, reason: "desktop auth secret not registered" }; + } + const result = await dialog.showOpenDialog({ properties: ["openDirectory", "createDirectory"] }); + if (result.canceled || result.filePaths.length === 0) { + return { ok: false, canceled: true }; + } + return await mintHomeWorkingDirToken({ + baseDir: result.filePaths[0], + desktopAuthSecret: options.desktopAuthSecret, + registerDesktopAuth: options.registerDesktopAuthWithDaemon, + }); + }); // shell.openPath opens an absolute filesystem path in the OS file // manager (Finder / Explorer / Files). It resolves to '' on success // and to a non-empty error string on failure (per Electron's diff --git a/apps/desktop/tests/main/mint-home-working-dir-token.test.ts b/apps/desktop/tests/main/mint-home-working-dir-token.test.ts new file mode 100644 index 0000000000..632938ab45 --- /dev/null +++ b/apps/desktop/tests/main/mint-home-working-dir-token.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; + +import { mintHomeWorkingDirToken } from "../../src/main/runtime.js"; + +const SECRET = Buffer.from("test-desktop-auth-secret"); + +describe("mintHomeWorkingDirToken", () => { + it("runs the desktop-auth handshake before minting the token", async () => { + const calls: string[] = []; + const registerDesktopAuth = vi.fn(async () => { + calls.push("register"); + return true; + }); + const mintToken = vi.fn((_secret: Buffer, baseDir: string) => { + calls.push("mint"); + return `token:${baseDir}`; + }); + + const result = await mintHomeWorkingDirToken({ + baseDir: "/Users/me/external", + desktopAuthSecret: SECRET, + registerDesktopAuth, + mintToken, + }); + + expect(result).toEqual({ baseDir: "/Users/me/external", ok: true, token: "token:/Users/me/external" }); + // Handshake must complete before the token is minted, otherwise the token + // is bound to a secret the daemon may not yet know (DESKTOP_AUTH_PENDING). + expect(calls).toEqual(["register", "mint"]); + }); + + it("fails without minting when the desktop-auth handshake fails", async () => { + const registerDesktopAuth = vi.fn(async () => false); + const mintToken = vi.fn(() => "should-not-be-minted"); + + const result = await mintHomeWorkingDirToken({ + baseDir: "/Users/me/external", + desktopAuthSecret: SECRET, + registerDesktopAuth, + mintToken, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toMatch(/desktop auth handshake/i); + } + // A guaranteed-unknown token is worse than a clear up-front failure, so we + // must not mint when the handshake did not succeed. + expect(mintToken).not.toHaveBeenCalled(); + }); + + it("rejects an empty picked path", async () => { + const mintToken = vi.fn(() => "x"); + const result = await mintHomeWorkingDirToken({ + baseDir: " ", + desktopAuthSecret: SECRET, + registerDesktopAuth: vi.fn(async () => true), + mintToken, + }); + + expect(result.ok).toBe(false); + expect(mintToken).not.toHaveBeenCalled(); + }); + + it("trims the picked path before binding the token to it", async () => { + const mintToken = vi.fn((_secret: Buffer, baseDir: string) => `token:${baseDir}`); + const result = await mintHomeWorkingDirToken({ + baseDir: " /Users/me/external ", + desktopAuthSecret: SECRET, + registerDesktopAuth: vi.fn(async () => true), + mintToken, + }); + + expect(result).toEqual({ baseDir: "/Users/me/external", ok: true, token: "token:/Users/me/external" }); + expect(mintToken).toHaveBeenCalledWith(SECRET, "/Users/me/external"); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 40da7da677..6ae58c48b0 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -20,6 +20,7 @@ import { MarketplaceView } from './components/MarketplaceView'; import { PluginDetailView } from './components/PluginDetailView'; import type { CreateInput, ImportClaudeDesignOutcome } from './components/NewProjectPanel'; import { MemoryToast } from './components/MemoryToast'; +import { Toast } from './components/Toast'; import { PetOverlay, type PetTaskCenter } from './components/pet/PetOverlay'; import { buildPetTaskCenter } from './components/pet/taskCenter'; import { migrateCustomPetAtlas } from './components/pet/pets'; @@ -51,6 +52,7 @@ import { fetchPromptTemplates, fetchSkills, uploadProjectFiles, + replaceProjectWorkingDir, } from './providers/registry'; import { RUNS_CHANGED_EVENT, @@ -316,6 +318,11 @@ function AppInner() { const latestPersistedConfigRef = useRef(config); latestPersistedConfigRef.current = config; const [settingsOpen, setSettingsOpen] = useState(false); + // Surfaced when a Home-picked working dir could not be applied to a freshly + // created project (expired/invalid desktop token, daemon rejection). Without + // this the failure was swallowed and the user believed their folder was in + // effect while the project actually stayed in the managed root. + const [workingDirError, setWorkingDirError] = useState(null); const [settingsWelcome, setSettingsWelcome] = useState(false); const [settingsInitialSection, setSettingsInitialSection] = useState('execution'); const [settingsHighlight, setSettingsHighlight] = useState(null); @@ -1159,6 +1166,7 @@ function AppInner() { autoSendFirstMessage?: boolean; requestId?: string; pendingFiles?: File[]; + userWorkingDirToken?: string; }, ): Promise => { // Honor an explicit `null` design system — the create panel defaults @@ -1206,8 +1214,42 @@ function AppInner() { const pendingFiles = Array.isArray(input.pendingFiles) ? input.pendingFiles.filter((file): file is File => file instanceof File) : []; + // Flip the project onto the user-picked working directory BEFORE + // uploading staged Home attachments. `replaceProjectWorkingDir` changes + // `metadata.baseDir`, so the project starts reading from the external + // folder. If we uploaded first, the staged files would land in the + // temporary managed `.od/projects/` root and then silently vanish + // from Design Files and the first auto-send context once the working + // dir flips. Doing the handoff first means the initial upload lands in + // the final tree. + const userWorkingDir = input.metadata?.userWorkingDir; + let workingDirHandoffFailed = false; + if (userWorkingDir) { + try { + await replaceProjectWorkingDir( + result.project.id, + userWorkingDir, + input.userWorkingDirToken, + ); + } catch (err) { + // The desktop working-dir token is short-lived (~60s TTL); if the + // user lingered on Home or the POST was otherwise rejected, the + // handoff fails AFTER the project already exists. Do NOT swallow + // this and do NOT proceed: uploading staged attachments or + // auto-sending the first message would target the managed + // `.od/projects/` root the user did not choose. Mark the + // handoff as failed so the upload + auto-send branches below are + // skipped, then surface a create-time error so the user can + // re-pick the working directory from inside the project. + console.warn('Failed to set working directory for new project', userWorkingDir, err); + workingDirHandoffFailed = true; + setWorkingDirError( + `Couldn't apply the chosen folder "${userWorkingDir}". The project was created in the default location — re-pick the working directory from the project before uploading files or sending a message.`, + ); + } + } let firstMessageAttachments: ChatAttachment[] = []; - if (pendingFiles.length > 0) { + if (!workingDirHandoffFailed && pendingFiles.length > 0) { // Home composer attaches stay client-side until submit lands a // project; the actual upload happens here. v2 doc wants one // file_upload_result per surface — `page_name='home'` / @@ -1250,6 +1292,7 @@ function AppInner() { // pre-filling the composer. Scoped to sessionStorage so a page // reload after the run has started does not refire. if ( + !workingDirHandoffFailed && input.autoSendFirstMessage && (derivedPendingPrompt !== undefined || firstMessageAttachments.length > 0) ) { @@ -1997,6 +2040,13 @@ function AppInner() { ) : null} openSettings('memory')} /> + {workingDirError ? ( + setWorkingDirError(null)} + /> + ) : null} {/* First-run privacy consent banner. It waits for daemon config hydration because privacyDecisionAt is daemon-owned and stripped from localStorage. It waits for `onboardingCompleted` so first-run diff --git a/apps/web/src/components/DesignFilesPanel.tsx b/apps/web/src/components/DesignFilesPanel.tsx index 56f2708464..68f7311da3 100644 --- a/apps/web/src/components/DesignFilesPanel.tsx +++ b/apps/web/src/components/DesignFilesPanel.tsx @@ -4,10 +4,9 @@ import { useAnalytics } from '../analytics/provider'; import { trackFileManagerClick } from '../analytics/events'; import { useT } from '../i18n'; import type { Dict } from '../i18n/types'; -import { copyToClipboard } from '../lib/copy-to-clipboard'; import { projectFileUrl, projectRawUrl } from '../providers/registry'; import { buildSrcdoc } from '../runtime/srcdoc'; -import type { LiveArtifactWorkspaceEntry, ProjectFile, ProjectFileKind, ProjectFolder } from '../types'; +import type { LiveArtifactWorkspaceEntry, ProjectFile, ProjectFileKind } from '../types'; import { createFileSystemReadError, FILE_SYSTEM_READ_ERROR_MESSAGE, @@ -23,16 +22,16 @@ type TranslateFn = (key: keyof Dict, vars?: Record) => interface Props { projectId: string; - /** Absolute on-disk project directory, used to build absolute file paths - * for the "copy path" menu action. */ - resolvedDir?: string; + // Basename of the project's working directory when the user has chosen a + // real folder (e.g. "openclaw"). Shown as the breadcrumb root instead of + // the generic "project" label. Undefined for default-storage projects. + rootDirName?: string; + // True while the host is reindexing a freshly replaced working dir. Drives + // a loading overlay so the panel doesn't sit silently on the stale tree. + reloading?: boolean; files: ProjectFile[]; - folders?: ProjectFolder[]; liveArtifacts: LiveArtifactWorkspaceEntry[]; onRefreshFiles: () => Promise | void; - /** Notified whenever the viewed folder changes, so the host can route new - * files (uploads, pastes, sketches) into that folder. '' means project root. */ - onCurrentDirChange?: (dir: string) => void; onOpenFile: (name: string) => void; onOpenLiveArtifact: (tabId: LiveArtifactWorkspaceEntry['tabId']) => void; onRenameFile: (from: string, to: string) => Promise | ProjectFile | null; @@ -40,8 +39,6 @@ interface Props { onDeleteFiles: (names: string[]) => Promise | void; onUpload: () => void; onUploadFiles: (files: File[]) => void; - onCreateFolder?: (name: string) => Promise | ProjectFolder | null; - onDeleteFolder?: (folderPath: string) => Promise | boolean; onPaste: () => void; onNewSketch: () => void; uploadError?: string | null; @@ -59,79 +56,39 @@ interface ActionNotice { url?: string; } -type DesignFilesGroupMode = 'kind' | 'modified'; -type ModifiedSection = 'today' | 'yesterday' | 'previous7Days' | 'previous30Days' | 'older'; -type SortKey = 'name' | 'kind' | 'mtime'; -type SortDir = 'asc' | 'desc'; +// Display-only refinement of ProjectFileKind. The contract `kind` lumps all +// source under `code`; the Design Files surface splits CSS/SCSS/etc. into a +// dedicated "Stylesheets" section to mirror Claude Design. Everything else +// maps 1:1 to its kind. +type FileCategory = ProjectFileKind | 'stylesheet'; -interface DirectoryRow { - label: string; - path: string; +// Section render order. Empty categories are skipped; the FOLDERS section is +// pinned above all of these from the directory list. +const SECTION_ORDER: FileCategory[] = [ + 'html', + 'stylesheet', + 'code', + 'document', + 'text', + 'image', + 'sketch', + 'pdf', + 'presentation', + 'spreadsheet', + 'video', + 'audio', + 'binary', +]; + +const STYLESHEET_EXTENSIONS = new Set(['css', 'scss', 'sass', 'less']); + +function fileCategory(file: ProjectFile): FileCategory { + const dot = file.name.lastIndexOf('.'); + const ext = dot >= 0 ? file.name.slice(dot + 1).toLowerCase() : ''; + if (STYLESHEET_EXTENSIONS.has(ext)) return 'stylesheet'; + return file.kind; } -// Storage key for per-project view state. Bump the version suffix (v1 → v2) when -// removing or renaming a persisted field — just adding an optional field is safe -// without a version bump. No cleanup of old keys on project deletion; the keys -// are small preference blobs and orphan gracefully. -const VIEW_STATE_KEY_PREFIX = 'od:design-files:view-state:v1:'; - -const DEFAULT_SORT_KEY: SortKey = 'mtime'; -const DEFAULT_SORT_DIR: SortDir = 'desc'; -const DEFAULT_PAGE_SIZE: number | 'all' = 30; -const PAGE_SIZE_OPTIONS = [15, 30, 45, 60, 'all'] as const; - -interface PersistedViewState { - sortKey?: SortKey; - sortDir?: SortDir; - pageSize?: number | 'all'; - kindFilter?: string[]; -} - -function readViewState(projectId: string): PersistedViewState { - try { - if (typeof window === 'undefined') return {}; - const raw = localStorage.getItem(VIEW_STATE_KEY_PREFIX + projectId); - if (!raw) return {}; - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; - return parsed as PersistedViewState; - } catch { - return {}; - } -} - -function writeViewState(projectId: string, state: PersistedViewState): void { - try { - localStorage.setItem(VIEW_STATE_KEY_PREFIX + projectId, JSON.stringify(state)); - } catch { - // localStorage unavailable (private mode, quota exceeded) — silently skip - } -} - -function isSortKey(v: unknown): v is SortKey { - return v === 'name' || v === 'kind' || v === 'mtime'; -} - -function isSortDir(v: unknown): v is SortDir { - return v === 'asc' || v === 'desc'; -} - -function isPageSize(v: unknown): v is number | 'all' { - return (PAGE_SIZE_OPTIONS as ReadonlyArray).includes(v); -} - -// Validate that a value is one of the known ProjectFileKind literals. This -// guards against stored values that were valid under a previous schema but -// are no longer part of the union — they are silently dropped rather than -// poisoning the kindFilter state. -const VALID_KIND_SET: ReadonlySet = new Set([ - 'html', 'image', 'video', 'audio', 'sketch', 'text', - 'code', 'pdf', 'document', 'presentation', 'spreadsheet', 'binary', -]); - -function isProjectFileKind(v: unknown): v is ProjectFileKind { - return typeof v === 'string' && VALID_KIND_SET.has(v); -} type FileSystemEntryWithReader = FileSystemEntry & { createReader?: () => FileSystemDirectoryReader; }; @@ -145,21 +102,6 @@ type DataTransferItemWithEntry = DataTransferItem & { webkitGetAsEntry?: () => FileSystemEntry | null; }; -const MODIFIED_SECTION_ORDER: ModifiedSection[] = [ - 'today', - 'yesterday', - 'previous7Days', - 'previous30Days', - 'older', -]; -const MODIFIED_SECTION_LABEL_KEY: Record = { - today: 'designFiles.modifiedToday', - yesterday: 'designFiles.modifiedYesterday', - previous7Days: 'designFiles.modifiedPrevious7Days', - previous30Days: 'designFiles.modifiedPrevious30Days', - older: 'designFiles.modifiedOlder', -}; - function buildActionNotice(message: string, url?: string): ActionNotice { const trimmedMessage = message.trim(); const trimmedUrl = url?.trim(); @@ -191,18 +133,18 @@ function ActionNoticeView({ notice }: { notice: ActionNotice | null }) { /** * Full-panel browser for a project's `.od/projects//` folder. Mirrors - * Claude Design's "Design Files" surface: grouped sections, hover-revealed - * row menu, drop-files footer, and (when a row is selected) a right-side - * preview pane. Triggered as a sticky first tab in FileWorkspace. + * Claude Design's "Design Files" surface: a single-line toolbar (up / refresh + * / breadcrumbs + actions), semantic sections (Folders, Stylesheets, Scripts, + * Documents, Images …), hover-revealed row checkbox + menu, a right-side + * preview pane, and a static "useful info" footer. Triggered as a sticky + * first tab in FileWorkspace. */ export function DesignFilesPanel({ projectId, - resolvedDir, + rootDirName, + reloading, files, - folders = [], liveArtifacts, - onRefreshFiles, - onCurrentDirChange, onOpenFile, onOpenLiveArtifact, onRenameFile, @@ -210,8 +152,6 @@ export function DesignFilesPanel({ onDeleteFiles, onUpload, onUploadFiles, - onCreateFolder, - onDeleteFolder, onPaste, onNewSketch, uploadError = null, @@ -222,156 +162,30 @@ export function DesignFilesPanel({ }: Props) { const t = useT(); const analytics = useAnalytics(); - const [refreshing, setRefreshing] = useState(false); const [draggingFiles, setDraggingFiles] = useState(false); const [dropReadError, setDropReadError] = useState(null); const dragDepthRef = useRef(0); - const internalDragNamesRef = useRef([]); + const [hover, setHover] = useState(null); const [menuPos, setMenuPos] = useState<{ name: string; top: number; left: number } | null>(null); - // Folder rows get their own context menu (open / copy path / delete); `path` - // is the folder's project-relative path. - const [folderMenuPos, setFolderMenuPos] = useState<{ path: string; top: number; left: number } | null>(null); - // Transient confirmation toast shown after an action (copy path, create / - // delete folder, move files). Auto-dismisses. - const [toast, setToast] = useState(null); - const MENU_ESTIMATED_HEIGHT = 180; + const MENU_ESTIMATED_HEIGHT = 145; const MENU_SAFE_PADDING = 8; const [preview, setPreview] = useState(null); const [selected, setSelected] = useState>(new Set()); - // Read once at mount; projectId is stable for this component instance - // (parent uses key={projectId} to remount on project switch). - const savedViewState = useRef(readViewState(projectId)); - // Guard for the persist useEffect: skip the initial write so we only - // flush to localStorage when the user actually changes a preference. - // Without this, every project the user opens gets a default-value entry - // written on first render, making stale-key garbage grow unbounded. - // Note: React 18 StrictMode (active in next dev) fires effects twice, - // keeping refs intact across the simulated remount. This means the guard - // fires on the first effect run, sets the ref true, and the second run - // then writes the defaults. The result is a harmless default-value entry - // for the project; subsequent user changes overwrite it correctly. The - // invariant ("no write without a user action") only holds in production - // builds where StrictMode is not active. - const viewStateHasMounted = useRef(false); - const [sortKey, setSortKey] = useState( - () => isSortKey(savedViewState.current.sortKey) ? savedViewState.current.sortKey : DEFAULT_SORT_KEY, - ); - const [sortDir, setSortDir] = useState( - () => isSortDir(savedViewState.current.sortDir) ? savedViewState.current.sortDir : DEFAULT_SORT_DIR, - ); const lastKeyPress = useRef>(new Map()); const [deleting, setDeleting] = useState(false); const [installingFolder, setInstallingFolder] = useState(null); const [sharingFolder, setSharingFolder] = useState(null); const [installNotice, setInstallNotice] = useState(null); - const [groupMode, setGroupMode] = useState('kind'); - const [collapsedModifiedSections, setCollapsedModifiedSections] = useState< - Set - >(new Set()); const [renaming, setRenaming] = useState<{ name: string; draft: string; saving: boolean } | null>(null); - // In-app dialogs for folder name input and move-to-folder. These replace - // window.prompt(), which throws ("prompt() is not supported.") in the - // Electron desktop host and silently aborted folder creation / moves there. - const [folderDialogDefault, setFolderDialogDefault] = useState(null); - const [moveDialogNames, setMoveDialogNames] = useState(null); - const [dayBoundary, setDayBoundary] = useState(() => Date.now()); - const [kindFilter, setKindFilter] = useState>(() => { - const { kindFilter: kf } = savedViewState.current; - if (!Array.isArray(kf) || kf.length === 0) return new Set(); - // Validate each stored value against the current ProjectFileKind union so - // stale values from a prior schema (e.g. a renamed kind) are dropped silently. - return new Set(kf.filter(isProjectFileKind)); - }); - const [filterMenuOpen, setFilterMenuOpen] = useState(false); - const filterMenuRef = useRef(null); const [currentDir, setCurrentDir] = useState(''); - const [optimisticFolderPaths, setOptimisticFolderPaths] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); - const [searchExpanded, setSearchExpanded] = useState(false); - const searchInputRef = useRef(null); - const [dragOverFolder, setDragOverFolder] = useState(null); - - const folderPaths = useMemo(() => { - const dirs = new Set(); - const addAncestors = (name: string) => { - const parts = name.split('/').filter(Boolean); - const max = name.endsWith('/') ? parts.length : parts.length - 1; - for (let i = 1; i <= max; i += 1) { - dirs.add(parts.slice(0, i).join('/')); - } - }; - for (const f of files) addAncestors(f.name); - for (const folder of folders) { - const path = folder.path || folder.name; - const parts = path.split('/').filter(Boolean); - for (let i = 1; i <= parts.length; i += 1) { - dirs.add(parts.slice(0, i).join('/')); - } - } - for (const folderPath of optimisticFolderPaths) addAncestors(`${folderPath}/`); - return [...dirs].sort((a, b) => a.localeCompare(b)); - }, [files, folders, optimisticFolderPaths]); - - const folderPathSet = useMemo(() => new Set(folderPaths), [folderPaths]); - - // Modified time per folder path, from the persisted folder listing. Derived - // folders (inferred from a file's path) and freshly-created optimistic - // folders have no entry, so their row shows no time until the next refresh. - const folderMtimeByPath = useMemo(() => { - const map = new Map(); - for (const folder of folders) { - const p = folder.path || folder.name; - if (p) map.set(p, folder.mtime); - } - return map; - }, [folders]); - - // Recursive descendant count per folder path, in ONE pass over `files`. - // Each dir row previously ran `files.filter(f => f.name.startsWith(prefix))`, - // making rendering O(dirs × files); this makes it O(files) once + an O(1) - // lookup per row. Walking each entry's `/` boundaries and incrementing every - // ancestor prefix reproduces the old startsWith(`${folder}/`) count exactly. - const fileCountByFolder = useMemo(() => { - const counts = new Map(); - for (const f of files) { - const name = f.name; - let idx = name.indexOf('/'); - while (idx !== -1) { - const folder = name.slice(0, idx); - counts.set(folder, (counts.get(folder) ?? 0) + 1); - idx = name.indexOf('/', idx + 1); - } - } - return counts; - }, [files]); - - const normalizedSearchQuery = searchQuery.trim().toLocaleLowerCase(); - const searchActive = normalizedSearchQuery.length > 0; // Derive immediate subdirectories and files at the current directory level - // from the flat files list and persisted empty folders. With a search query, - // switch to a project-wide result set so nested files are discoverable. + // from the flat files list. Files with names like "a/b/c.html" contribute + // "a" as a directory when currentDir is '' and "b" when currentDir is "a". const { dirsAtCurrentDir, filesAtCurrentDir } = useMemo(() => { - if (normalizedSearchQuery) { - return { - dirsAtCurrentDir: folderPaths - .filter((folderPath) => folderPath.toLocaleLowerCase().includes(normalizedSearchQuery)) - .map((folderPath) => ({ label: folderPath, path: folderPath })), - filesAtCurrentDir: files.filter((f) => f.name.toLocaleLowerCase().includes(normalizedSearchQuery)), - }; - } const prefix = currentDir === '' ? '' : `${currentDir}/`; + const dirs = new Set(); const localFiles: ProjectFile[] = []; - const localDirs = new Map(); - for (const folderPath of folderPaths) { - if (!folderPath.startsWith(prefix)) continue; - const remainder = folderPath.slice(prefix.length); - if (!remainder) continue; - const slashIdx = remainder.indexOf('/'); - const label = slashIdx === -1 ? remainder : remainder.slice(0, slashIdx); - const path = currentDir === '' ? label : `${currentDir}/${label}`; - localDirs.set(path, { label, path }); - } for (const f of files) { if (!f.name.startsWith(prefix)) continue; const remainder = f.name.slice(prefix.length); @@ -379,248 +193,56 @@ export function DesignFilesPanel({ if (slashIdx === -1) { localFiles.push(f); } else { - const label = remainder.slice(0, slashIdx); - const path = currentDir === '' ? label : `${currentDir}/${label}`; - localDirs.set(path, { label, path }); + dirs.add(remainder.slice(0, slashIdx)); } } return { - dirsAtCurrentDir: [...localDirs.values()].sort((a, b) => a.label.localeCompare(b.label)), + dirsAtCurrentDir: [...dirs].sort((a, b) => a.localeCompare(b)), filesAtCurrentDir: localFiles, }; - }, [currentDir, files, folderPaths, normalizedSearchQuery]); + }, [files, currentDir]); - const kindCounts = useMemo(() => { - const counts = new Map(); - for (const f of filesAtCurrentDir) counts.set(f.kind, (counts.get(f.kind) ?? 0) + 1); - return counts; + // Group files at the current level into semantic sections, ordered by + // SECTION_ORDER. Files within a section sort most-recently-modified first. + const sections = useMemo(() => { + const grouped = new Map(); + for (const f of filesAtCurrentDir) { + const category = fileCategory(f); + const bucket = grouped.get(category) ?? []; + bucket.push(f); + grouped.set(category, bucket); + } + for (const bucket of grouped.values()) { + bucket.sort((a, b) => b.mtime - a.mtime); + } + return SECTION_ORDER.filter((category) => grouped.has(category)).map( + (category) => [category, grouped.get(category)!] as const, + ); }, [filesAtCurrentDir]); - const availableKinds = useMemo( - () => - Array.from(kindCounts.keys()).sort( - (a, b) => kindSortPriority(a) - kindSortPriority(b), - ), - [kindCounts], - ); - - // Drop any selected-filter kinds that no longer appear in the file list - // (e.g. after a delete leaves the kind empty). Keeps the filter UI honest - // and prevents a stale filter from silently hiding everything. - // Guard: skip when no kinds are available yet — availableKinds is empty only - // when files haven't loaded. Running cleanup against an empty set would - // clear a kindFilter that was correctly restored from localStorage before - // the async file list arrived. + // Reset selection and renaming state when the user navigates into or out of + // a directory. useEffect(() => { - if (availableKinds.length === 0) return; - setKindFilter((prev) => { - if (prev.size === 0) return prev; - const present = new Set(availableKinds); - const next = new Set(); - let changed = false; - for (const k of prev) { - if (present.has(k)) next.add(k); - else changed = true; - } - return changed ? next : prev; - }); - }, [availableKinds]); - - const filteredFiles = useMemo(() => { - if (kindFilter.size === 0) return filesAtCurrentDir; - return filesAtCurrentDir.filter((f) => kindFilter.has(f.kind)); - }, [filesAtCurrentDir, kindFilter]); - - const sortedFiles = useMemo(() => { - return [...filteredFiles].sort((a, b) => { - let cmp: number; - if (sortKey === 'name') cmp = a.name.localeCompare(b.name); - else if (sortKey === 'kind') cmp = kindSortPriority(a.kind) - kindSortPriority(b.kind); - else cmp = a.mtime - b.mtime; - return sortDir === 'asc' ? cmp : -cmp; - }); - }, [filteredFiles, sortKey, sortDir]); - - const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState( - () => isPageSize(savedViewState.current.pageSize) ? savedViewState.current.pageSize : DEFAULT_PAGE_SIZE, - ); - - const effectivePageSize = pageSize === 'all' ? Math.max(1, sortedFiles.length) : pageSize; - const totalPages = Math.max(1, Math.ceil(sortedFiles.length / effectivePageSize)); - const safePage = Math.min(page, totalPages - 1); - const pageFiles = useMemo( - () => - sortedFiles.slice( - safePage * effectivePageSize, - (safePage + 1) * effectivePageSize, - ), - [effectivePageSize, safePage, sortedFiles], - ); - const modifiedGroups = useMemo(() => { - const groups: Record = { - today: [], - yesterday: [], - previous7Days: [], - previous30Days: [], - older: [], - }; - const thresholds = modifiedSectionThresholds(dayBoundary); - for (const f of pageFiles) { - groups[modifiedSectionFor(f.mtime, thresholds)].push(f); - } - return groups; - }, [dayBoundary, pageFiles]); - const visibleModifiedSections = MODIFIED_SECTION_ORDER.filter( - (section) => modifiedGroups[section].length > 0, - ); - const rangeStart = safePage * effectivePageSize + 1; - const rangeEnd = Math.min((safePage + 1) * effectivePageSize, sortedFiles.length); - const allPageSelected = pageFiles.length > 0 && pageFiles.every((f) => selected.has(f.name)); - const somePageSelected = !allPageSelected && pageFiles.some((f) => selected.has(f.name)); - const hasMultiplePages = totalPages > 1; - const showListControls = sortedFiles.length > 15 || selected.size > 0; - - useEffect(() => { - setPage(0); - }, [pageSize]); - - // Persist view state so it survives navigation (the panel remounts via - // key={projectId} when the user tabs away and back). - // Skip the initial render: we only want to write when the user actually - // changes a preference, not on every project the user visits. - useEffect(() => { - if (!viewStateHasMounted.current) { - viewStateHasMounted.current = true; - return; - } - writeViewState(projectId, { - sortKey, - sortDir, - pageSize, - kindFilter: Array.from(kindFilter), - }); - }, [projectId, sortKey, sortDir, pageSize, kindFilter]); - - // Reset to the first page when the filter changes — the previous page - // index may no longer exist (or may now sit past the new totalPages). - useEffect(() => { - setPage(0); - }, [kindFilter]); - - useEffect(() => { - setPage(0); - }, [normalizedSearchQuery]); - - useEffect(() => { - if (!searchExpanded) return; - searchInputRef.current?.focus(); - searchInputRef.current?.select(); - }, [searchExpanded]); - - // Drop any selected files that fall outside the active filter. Without - // this, bulk delete / download would silently operate on rows the user - // can no longer see — particularly dangerous for destructive deletes. - // We keep the empty-filter branch a no-op so clearing the filter - // doesn't disturb existing selections. - useEffect(() => { - if (kindFilter.size === 0) return; - setSelected((prev) => { - if (prev.size === 0) return prev; - const visible = new Set(filteredFiles.map((f) => f.name)); - const next = new Set(); - let changed = false; - for (const name of prev) { - if (visible.has(name)) next.add(name); - else changed = true; - } - return changed ? next : prev; - }); - }, [filteredFiles, kindFilter]); - - // Reset page, selection, and renaming state when the user navigates - // into or out of a directory. - useEffect(() => { - setPage(0); setSelected(new Set()); setRenaming(null); }, [currentDir]); - // Tell the host which folder is being viewed so new files (uploads, pastes, - // sketches, dropped files) are created under it instead of the project root. - useEffect(() => { - onCurrentDirChange?.(currentDir); - }, [currentDir, onCurrentDirChange]); - - // Auto-dismiss the confirmation toast. - useEffect(() => { - if (!toast) return; - const timer = window.setTimeout(() => setToast(null), 1800); - return () => window.clearTimeout(timer); - }, [toast]); - // Navigate up to the nearest ancestor that still exists when files under // currentDir disappear (e.g. after deleting the last file in a subfolder). useEffect(() => { if (currentDir === '') return; const prefix = `${currentDir}/`; - if (folderPathSet.has(currentDir) || files.some((f) => f.name.startsWith(prefix))) return; + if (files.some((f) => f.name.startsWith(prefix))) return; const parts = currentDir.split('/'); for (let i = parts.length - 1; i > 0; i--) { const ancestor = parts.slice(0, i).join('/'); - if (folderPathSet.has(ancestor) || files.some((f) => f.name.startsWith(`${ancestor}/`))) { + if (files.some((f) => f.name.startsWith(`${ancestor}/`))) { setCurrentDir(ancestor); return; } } setCurrentDir(''); - }, [files, folderPathSet, currentDir]); - - // Outside-click + escape to close the filter popover. Stops short of a - // full focus trap because the popover hosts only checkboxes plus a - // small clear button; the existing tab order through them is fine. - useEffect(() => { - if (!filterMenuOpen) return; - const onMouseDown = (event: MouseEvent) => { - const root = filterMenuRef.current; - if (root && event.target instanceof Node && !root.contains(event.target)) { - setFilterMenuOpen(false); - } - }; - const onKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') setFilterMenuOpen(false); - }; - window.addEventListener('mousedown', onMouseDown); - window.addEventListener('keydown', onKey); - return () => { - window.removeEventListener('mousedown', onMouseDown); - window.removeEventListener('keydown', onKey); - }; - }, [filterMenuOpen]); - - function toggleKindFilter(kind: ProjectFileKind): void { - setKindFilter((prev) => { - const next = new Set(prev); - if (next.has(kind)) next.delete(kind); - else next.add(kind); - return next; - }); - } - - useEffect(() => { - if (Number.isFinite(totalPages)) setPage((p) => Math.min(p, totalPages - 1)); - }, [totalPages]); - - useEffect(() => { - const now = Date.now(); - const startOfTomorrow = new Date(now); - startOfTomorrow.setHours(24, 0, 0, 0); - const timer = window.setTimeout( - () => setDayBoundary(Date.now()), - Math.max(1, startOfTomorrow.getTime() - now), - ); - return () => window.clearTimeout(timer); - }, [dayBoundary]); + }, [files, currentDir]); const pluginFolders = useMemo(() => getPluginFolderCandidates(files), [files]); @@ -650,11 +272,8 @@ export function DesignFilesPanel({ ); useEffect(() => { - if (!menuPos && !folderMenuPos) return; - const close = () => { - setMenuPos(null); - setFolderMenuPos(null); - }; + if (!menuPos) return; + const close = () => setMenuPos(null); const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close(); }; @@ -664,27 +283,8 @@ export function DesignFilesPanel({ window.removeEventListener('mousedown', close); window.removeEventListener('keydown', onKey); }; - }, [menuPos, folderMenuPos]); + }, [menuPos]); - async function handleRefresh() { - setRefreshing(true); - try { - await onRefreshFiles(); - } finally { - setRefreshing(false); - } - } - - function toggleSort(key: SortKey) { - return () => { - if (sortKey === key) { - setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); - } else { - setSortKey(key); - setSortDir('asc'); - } - }; - } function toggleSelect(name: string) { setSelected((prev) => { @@ -698,22 +298,6 @@ export function DesignFilesPanel({ }); } - function toggleSelectPage() { - setSelected((prev) => { - const next = new Set(prev); - if (allPageSelected) { - for (const f of pageFiles) next.delete(f.name); - } else { - for (const f of pageFiles) next.add(f.name); - } - return next; - }); - } - - function selectAllFiles() { - setSelected(new Set(sortedFiles.map((f) => f.name))); - } - function clearSelection() { setSelected(new Set()); } @@ -743,56 +327,6 @@ export function DesignFilesPanel({ setMenuPos({ name, top, left }); } - function openFolderMenuFor(path: string, el: HTMLElement) { - const rect = el.closest('.df-row-menu')?.getBoundingClientRect(); - if (!rect) return; - const viewportHeight = window.innerHeight; - const spaceBelow = viewportHeight - rect.bottom; - const spaceAbove = rect.top; - let top: number; - if (spaceBelow >= MENU_ESTIMATED_HEIGHT + MENU_SAFE_PADDING) { - top = rect.bottom + 4; - } else if (spaceAbove >= MENU_ESTIMATED_HEIGHT + MENU_SAFE_PADDING) { - top = rect.top - MENU_ESTIMATED_HEIGHT - 4; - } else { - top = Math.max(MENU_SAFE_PADDING, viewportHeight - MENU_ESTIMATED_HEIGHT - MENU_SAFE_PADDING); - } - const left = Math.max(MENU_SAFE_PADDING, rect.right - 160); - setMenuPos(null); - setFolderMenuPos({ path, top, left }); - } - - // Absolute on-disk path for a project-relative file/folder path. Falls back - // to the relative path when the project root is unknown (e.g. a non-desktop - // host that didn't resolve it). - function absPathFor(name: string): string { - const base = (resolvedDir ?? '').replace(/[/\\]+$/, ''); - return base ? `${base}/${name}` : name; - } - - async function copyPath(name: string) { - setMenuPos(null); - setFolderMenuPos(null); - const ok = await copyToClipboard(absPathFor(name)); - if (ok) setToast(t('designFiles.copiedPath')); - } - - async function handleDeleteFolder(path: string) { - setFolderMenuPos(null); - if (!onDeleteFolder) return; - const label = basenameForProjectPath(path); - if (!window.confirm(t('designFiles.deleteFolderConfirm', { name: label }))) return; - const ok = await onDeleteFolder(path); - if (ok) { - setOptimisticFolderPaths((current) => current.filter((p) => p !== path && !p.startsWith(`${path}/`))); - if (currentDir === path || currentDir.startsWith(`${path}/`)) { - const parent = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''; - setCurrentDir(parent); - } - setToast(t('designFiles.folderDeleted', { name: label })); - } - } - function startRename(name: string) { setMenuPos(null); setPreview(name); @@ -830,86 +364,6 @@ export function DesignFilesPanel({ } } - function handleCreateFolder() { - if (!onCreateFolder) return; - const suggestedPath = nextAvailableFolderPath(currentDir, folderPathSet, files); - setFolderDialogDefault(basenameForProjectPath(suggestedPath)); - } - - async function commitCreateFolder(rawName: string) { - setFolderDialogDefault(null); - if (!onCreateFolder) return; - const cleanName = rawName.trim().replace(/^\/+|\/+$/g, ''); - if (!cleanName) return; - const targetPath = - currentDir && !cleanName.startsWith(`${currentDir}/`) - ? joinProjectPath(currentDir, cleanName) - : cleanName; - const folder = await onCreateFolder(targetPath); - const createdPath = folder?.path || targetPath; - if (createdPath) { - setOptimisticFolderPaths((current) => - current.includes(createdPath) ? current : [...current, createdPath], - ); - setSearchQuery(''); - setCurrentDir(createdPath); - setToast(t('designFiles.folderCreated', { name: basenameForProjectPath(createdPath) })); - } - } - - function displayNameForFile(name: string): string { - if (searchActive || currentDir === '') return name; - return name.slice(currentDir.length + 1); - } - - function openDirectory(path: string): void { - setSearchQuery(''); - setCurrentDir(path); - } - - async function moveFilesToFolder(names: string[], folderPath: string) { - const uniqueNames = Array.from(new Set(names)); - const failed: string[] = []; - let moved = 0; - for (const name of uniqueNames) { - const nextName = joinProjectPath(folderPath, basenameForProjectPath(name)); - if (nextName === name) continue; - try { - const renamed = await onRenameFile(name, nextName); - if (!renamed) throw new Error('Move failed'); - moved += 1; - setPreview((curr) => (curr === name ? renamed.name : curr)); - setSelected((prev) => { - if (!prev.has(name)) return prev; - const next = new Set(prev); - next.delete(name); - next.add(renamed.name); - return next; - }); - } catch (err) { - failed.push(`${name}: ${err instanceof Error ? err.message : String(err)}`); - } - } - if (failed.length > 0) { - alert(`Could not move ${failed.length} file(s).\n${failed.slice(0, 4).join('\n')}`); - } else if (moved > 0) { - const dest = folderPath ? basenameForProjectPath(folderPath) : t('designFiles.moveRoot'); - setToast(t('designFiles.filesMoved', { n: moved, dest })); - } - } - - function startMove(name: string) { - setMenuPos(null); - setMoveDialogNames([name]); - } - - function commitMove(targetPath: string) { - const names = moveDialogNames; - setMoveDialogNames(null); - if (!names || names.length === 0) return; - void moveFilesToFolder(names, targetPath.trim().replace(/^\/+|\/+$/g, '')); - } - async function handleBatchDelete() { if (deleting) return; const fileList = [...selected]; @@ -926,78 +380,48 @@ export function DesignFilesPanel({ } } - function toggleModifiedSection(section: ModifiedSection) { - setCollapsedModifiedSections((prev) => { - const next = new Set(prev); - if (next.has(section)) { - next.delete(section); - } else { - next.add(section); - } - return next; - }); - } - - function renderFileRow(f: ProjectFile) { + function renderFileRow(f: ProjectFile, category: FileCategory) { const active = preview === f.name; + const isSelected = selected.has(f.name); + const isHovered = hover === f.name; const renameState = renaming?.name === f.name ? renaming : null; return ( - { - const names = selected.has(f.name) ? Array.from(selected) : [f.name]; - internalDragNamesRef.current = names; - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('application/x-open-design-files', JSON.stringify(names)); - e.dataTransfer.setData('text/plain', names.join('\n')); - }} - onDragEnd={() => { - internalDragNamesRef.current = []; - setDragOverFolder(null); - }} + className={`df-row df-file-row ${active ? 'active' : ''} ${isSelected ? 'selected' : ''}`} + onMouseEnter={() => setHover(f.name)} + onMouseLeave={() => setHover((c) => (c === f.name ? null : c))} > - - { + { + e.stopPropagation(); + toggleSelect(f.name); + }} + role="checkbox" + aria-checked={isSelected} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); e.stopPropagation(); toggleSelect(f.name); - }} - role="checkbox" - aria-checked={selected.has(f.name)} - tabIndex={0} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - toggleSelect(f.name); - } - }} - > - {selected.has(f.name) ? '\u2611' : '\u2610'} - - - + {isSelected ? '☑' : '☐'} + + setPreview(f.name)} onDoubleClick={() => onOpenFile(f.name)} > - - {kindGlyph(f.kind)} - - - { - if (!renameState) setPreview(f.name); - }} - onDoubleClick={() => { - if (!renameState) onOpenFile(f.name); - }} - > + {categoryGlyph(category)} + +
{renameState ? ( setPreview(f.name)} + onDoubleClick={() => onOpenFile(f.name)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); @@ -1044,195 +469,73 @@ export function DesignFilesPanel({ }} > - - {displayNameForFile(f.name)} - - - {humanBytes(f.size)} - {kindLabel(f.kind, t)} - {relativeTime(f.mtime, t)} + + {currentDir === '' ? f.name : f.name.slice(currentDir.length + 1)} + {categoryLabel(category, t)} )} - - setPreview(f.name)} - onDoubleClick={() => onOpenFile(f.name)} - > - {kindLabel(f.kind, t)} - - + setPreview(f.name)} onDoubleClick={() => onOpenFile(f.name)} > {relativeTime(f.mtime, t)} - - - { + + { + e.stopPropagation(); + openMenuFor(f.name, e.target as HTMLElement); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); e.stopPropagation(); - openMenuFor(f.name, e.target as HTMLElement); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - openMenuFor(f.name, e.currentTarget as HTMLElement); - } - }} - > - ⋯ - - - + openMenuFor(f.name, e.currentTarget as HTMLElement); + } + }} + > + ⋯ + +
); } - function renderDirRow(dir: DirectoryRow) { - const fullPath = dir.path; - const count = fileCountByFolder.get(fullPath) ?? 0; - const dragActive = dragOverFolder === fullPath; - const folderMtime = folderMtimeByPath.get(fullPath); - const menuVisible = folderMenuPos?.path === fullPath; + function renderDirRow(dirName: string) { + const fullPath = currentDir === '' ? dirName : `${currentDir}/${dirName}`; + const prefix = `${fullPath}/`; + const count = files.filter((f) => f.name.startsWith(prefix)).length; return ( - { - if (!hasInternalFileDrag(e.dataTransfer)) return; - e.preventDefault(); - setDragOverFolder(fullPath); - }} - onDragOver={(e) => { - if (!hasInternalFileDrag(e.dataTransfer)) return; - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - setDragOverFolder(fullPath); - }} - onDragLeave={(e) => { - if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { - setDragOverFolder((current) => (current === fullPath ? null : current)); - } - }} - onDrop={(e) => { - if (!hasInternalFileDrag(e.dataTransfer)) return; - e.preventDefault(); - e.stopPropagation(); - const names = namesFromInternalDrag(e.dataTransfer, internalDragNamesRef.current); - internalDragNamesRef.current = []; - setDragOverFolder(null); - void moveFilesToFolder(names, fullPath); - }} - > - - openDirectory(fullPath)}> - - - - - openDirectory(fullPath)}> - - - openDirectory(fullPath)}> - {t('designFiles.kindFolder')} - - openDirectory(fullPath)}> - {folderMtime != null ? relativeTime(folderMtime, t) : null} - - - { - e.stopPropagation(); - openFolderMenuFor(fullPath, e.target as HTMLElement); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - openFolderMenuFor(fullPath, e.currentTarget as HTMLElement); - } - }} - > - ⋯ - - - + + + + ); } - function renderModifiedSections() { - const dirRows = dirsAtCurrentDir.map((d) => renderDirRow(d)); - const sectionRows = visibleModifiedSections.flatMap((section) => { - const sectionFiles = modifiedGroups[section]; - const collapsed = collapsedModifiedSections.has(section); - const label = t(MODIFIED_SECTION_LABEL_KEY[section]); - return [ - - - - - , - ...(collapsed ? [] : sectionFiles.map(renderFileRow)), - ]; - }); - return [...dirRows, ...sectionRows]; - } - - function renderKindSections() { - const dirRows = dirsAtCurrentDir.map((d) => renderDirRow(d)); - const grouped = new Map(); - for (const file of pageFiles) { - const next = grouped.get(file.kind) ?? []; - next.push(file); - grouped.set(file.kind, next); - } - - const kindRows = [...grouped.entries()] - .sort(([a], [b]) => kindSortPriority(a) - kindSortPriority(b)) - .flatMap(([kind, kindFiles]) => [ - - -
- {kindLabel(kind, t)} - {kindFiles.length} -
- - , - ...kindFiles.map(renderFileRow), - ]); - return [...dirRows, ...kindRows]; - } - async function handleBatchDownload() { const fileList = [...selected]; if (fileList.length === 0) return; @@ -1271,9 +574,7 @@ export function DesignFilesPanel({ } async function handleDrop(ev: React.DragEvent) { - if (!hasNativeFiles(ev.dataTransfer)) return; ev.preventDefault(); - ev.stopPropagation(); dragDepthRef.current = 0; setDraggingFiles(false); setDropReadError(null); @@ -1314,209 +615,84 @@ export function DesignFilesPanel({ } } - const refreshControl = ( - + const fileActions = ( +
+ + + +
); - const fileActions = - selected.size > 0 ? ( -
+ const breadcrumbs = ( +
- ) : ( -
- {onCreateFolder ? ( - - ) : null} - - - -
- ); - - const groupToggle = - files.length > 0 ? ( -
- {t('designFiles.groupBy')} - - -
- ) : null; - - const kindFilterControl = - files.length > 0 && availableKinds.length > 1 ? ( -
- + )} - {kindFilter.size > 0 ? ( - - {kindFilter.size} - - ) : null} - - {filterMenuOpen ? ( -
-
- {t('designFiles.filterBy')} - {kindFilter.size > 0 ? ( - - ) : null} -
-
    - {availableKinds.map((kind) => { - const checked = kindFilter.has(kind); - const count = kindCounts.get(kind) ?? 0; - return ( -
  • - -
  • - ); - })} -
-
- ) : null} -
- ) : null; + ); + })} + + ); const visibleUploadError = uploadError ?? dropReadError; + const hasSelection = selected.size > 0; return ( -
{ - if (!hasNativeFiles(ev.dataTransfer)) return; - ev.preventDefault(); - dragDepthRef.current += 1; - setDraggingFiles(true); - }} - onDragOver={(ev) => { - if (!hasNativeFiles(ev.dataTransfer)) return; - ev.preventDefault(); - ev.dataTransfer.dropEffect = 'copy'; - }} - onDragLeave={(ev) => { - if (!hasNativeFiles(ev.dataTransfer)) return; - if (!ev.currentTarget.contains(ev.relatedTarget as Node | null)) { - dragDepthRef.current = 0; - setDraggingFiles(false); - return; - } - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); - if (dragDepthRef.current === 0) setDraggingFiles(false); - }} - onDrop={handleDrop} - > +
+ {reloading ? ( +
+ + + {t('common.loading')} + +
+ ) : null}
+
+
{breadcrumbs}
+
{fileActions}
+
{visibleUploadError && !preview ? (
@@ -1535,105 +711,44 @@ export function DesignFilesPanel({ ) : null}
) : null} -
-
-
- {refreshControl} -
{ - if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { - setSearchExpanded(false); - } + {hasSelection ? ( +
+ + {t('designFiles.downloadSelected', { n: selected.size })} + +
+ - ) : null} - - ) : ( - - )} -
-
-
- {fileActions} + + {t('designFiles.download')} + + +
-
- {groupToggle} - {kindFilterControl} -
-
- {currentDir !== '' ? ( - ) : null} - {files.length === 0 && folderPaths.length === 0 && liveArtifacts.length === 0 ? ( + {files.length === 0 && liveArtifacts.length === 0 ? (
@@ -1764,150 +879,51 @@ export function DesignFilesPanel({ )})}
) : null} - {(sortedFiles.length > 0 || dirsAtCurrentDir.length > 0) ? ( - <> - {showListControls ? ( -
- - {!hasMultiplePages ? ( - - {t('designFiles.pageInfo', { start: rangeStart, end: rangeEnd, total: sortedFiles.length })} - - ) : null} -
- {selected.size < sortedFiles.length ? ( - - ) : null} - {selected.size > 0 ? ( - - ) : null} -
-
- ) : null} - - - - - - - - - - - {groupMode === 'modified' - ? renderModifiedSections() - : groupMode === 'kind' - ? renderKindSections() - : [...dirsAtCurrentDir.map(renderDirRow), ...pageFiles.map(renderFileRow)]} - -
- { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleSelectPage(); - } - }} - ref={(el) => { - if (el) (el as HTMLElement).ariaChecked = allPageSelected ? 'true' : somePageSelected ? 'mixed' : 'false'; - }} - > - {allPageSelected ? '\u2611' : somePageSelected ? '\u25A3' : '\u2610'} - - - - - - - - - -
- {hasMultiplePages ? ( -
- - - - - {t('designFiles.pageInfo', { start: rangeStart, end: rangeEnd, total: sortedFiles.length })} - -
- ) : null} - + {dirsAtCurrentDir.length > 0 ? ( +
+
+ {t('designFiles.sectionFolders')} + {dirsAtCurrentDir.length} +
+ {dirsAtCurrentDir.map((d) => renderDirRow(d))} +
) : null} + {sections.map(([category, sectionFiles]) => ( +
+
+ {sectionLabel(category, t)} + {sectionFiles.length} +
+ {sectionFiles.map((f) => renderFileRow(f, category))} +
+ ))} )} +
+ {t('designFiles.usefulInfoLabel')} + {t('designFiles.usefulInfoTip')} +
{ + ev.preventDefault(); + dragDepthRef.current += 1; + setDraggingFiles(true); + }} + onDragOver={(ev) => { + ev.preventDefault(); + ev.dataTransfer.dropEffect = 'copy'; + }} + onDragLeave={(ev) => { + if (!ev.currentTarget.contains(ev.relatedTarget as Node | null)) { + dragDepthRef.current = 0; + setDraggingFiles(false); + return; + } + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setDraggingFiles(false); + }} + onDrop={handleDrop} > {t('designFiles.dropTitle')} {t('designFiles.dropDesc')} @@ -1957,24 +973,6 @@ export function DesignFilesPanel({ > {t('common.rename')} - -
) : null} - {folderMenuPos ? ( -
e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - > - - - {onDeleteFolder ? ( - - ) : null} -
- ) : null} - {toast ? ( -
- {toast} -
- ) : null} - {folderDialogDefault !== null ? ( - setFolderDialogDefault(null)} - onSubmit={(name) => void commitCreateFolder(name)} - /> - ) : null} - {moveDialogNames !== null ? ( - setMoveDialogNames(null)} - onSubmit={commitMove} - /> - ) : null} -
- ); -} - -// Sentinel
)} {thumbCanOpen ? ( @@ -2303,28 +1077,23 @@ function DfPreview({ ) : null}
- +
{file.name}
-
{kindLabel(file.kind, t)}
+
{categoryLabel(fileCategory(file), t)}
- {t('designFiles.modified', { + {t('designFiles.modifiedExt', { time: relativeTime(file.mtime, t), size: humanBytes(file.size), + ext: fileExtensionLabel(file.name), })}
+ + + {t('designFiles.download')} +
); @@ -2370,108 +1139,45 @@ function baseDirForFile(name: string): string { return index >= 0 ? name.slice(0, index + 1) : ''; } -function basenameForProjectPath(name: string): string { - const parts = name.split('/').filter(Boolean); - return parts[parts.length - 1] ?? name; +function fileExtensionLabel(name: string): string { + const dot = name.lastIndexOf('.'); + if (dot < 0 || dot === name.length - 1) return ''; + return name.slice(dot + 1).toUpperCase(); } -function joinProjectPath(dir: string, basename: string): string { - const cleanDir = dir.trim().replace(/^\/+|\/+$/g, ''); - return cleanDir ? `${cleanDir}/${basename}` : basename; -} - -function nextAvailableFolderPath( - currentDir: string, - folderPathSet: ReadonlySet, - files: ProjectFile[], -): string { - const baseName = 'untitled-folder'; - const cleanDir = currentDir.trim().replace(/^\/+|\/+$/g, ''); - let suffix = 0; - while (suffix < 1000) { - const basename = suffix === 0 ? baseName : `${baseName}-${suffix + 1}`; - const candidate = joinProjectPath(cleanDir, basename); - const prefix = `${candidate}/`; - const exists = - folderPathSet.has(candidate) - || files.some((file) => file.name === candidate || file.name.startsWith(prefix)); - if (!exists) return candidate; - suffix += 1; +// Plural section header for a category. Reuses existing plural labels where a +// dedicated one exists; otherwise falls back to the singular type label so +// each category gets a distinct, readable header. +function sectionLabel(category: FileCategory, t: TranslateFn): string { + switch (category) { + case 'html': + return t('designFiles.sectionPages'); + case 'stylesheet': + return t('designFiles.sectionStylesheets'); + case 'code': + return t('designFiles.sectionScripts'); + case 'document': + return t('designFiles.sectionDocuments'); + case 'image': + return t('designFiles.sectionImages'); + case 'sketch': + return t('designFiles.sectionSketches'); + case 'binary': + return t('designFiles.sectionOther'); + default: + return categoryLabel(category, t); } - return joinProjectPath(cleanDir, `${baseName}-${Date.now().toString(36)}`); } -function hasNativeFiles(dataTransfer: DataTransfer): boolean { - if (Array.from(dataTransfer.types ?? []).includes('Files')) return true; - if ((dataTransfer.files?.length ?? 0) > 0) return true; - return Array.from(dataTransfer.items ?? []).some((item) => { - if (item.kind === 'file') return true; - return typeof (item as DataTransferItemWithEntry).webkitGetAsEntry === 'function'; - }); +// Singular row subtitle for a category. +function categoryLabel(category: FileCategory, t: TranslateFn): string { + if (category === 'stylesheet') return t('designFiles.kindStylesheet'); + return kindLabel(category, t); } -function hasInternalFileDrag(dataTransfer: DataTransfer): boolean { - return Array.from(dataTransfer.types ?? []).includes('application/x-open-design-files'); -} - -function namesFromInternalDrag(dataTransfer: DataTransfer, fallback: string[]): string[] { - const raw = dataTransfer.getData('application/x-open-design-files'); - if (!raw) return fallback; - try { - const parsed = JSON.parse(raw) as unknown; - if (Array.isArray(parsed)) return parsed.filter((item): item is string => typeof item === 'string'); - } catch { - // Fall through to the in-memory fallback from the current drag gesture. - } - return fallback; -} - -function kindSortPriority(kind: ProjectFileKind): number { - if (kind === 'html') return 0; - if (kind === 'text') return 1; - if (kind === 'code') return 2; - if (kind === 'sketch') return 3; - if (kind === 'image') return 4; - if (kind === 'document') return 5; - if (kind === 'pdf') return 6; - if (kind === 'presentation') return 7; - if (kind === 'spreadsheet') return 8; - if (kind === 'video') return 9; - if (kind === 'audio') return 10; - return 11; -} - -interface ModifiedSectionThresholds { - todayStart: number; - yesterdayStart: number; - previous7DaysStart: number; - previous30DaysStart: number; -} - -function modifiedSectionThresholds(now: number): ModifiedSectionThresholds { - const startOfToday = new Date(now); - startOfToday.setHours(0, 0, 0, 0); - return { - todayStart: startOfToday.getTime(), - yesterdayStart: dateDaysBefore(startOfToday, 1).getTime(), - previous7DaysStart: dateDaysBefore(startOfToday, 7).getTime(), - previous30DaysStart: dateDaysBefore(startOfToday, 30).getTime(), - }; -} - -function modifiedSectionFor(ts: number, thresholds: ModifiedSectionThresholds): ModifiedSection { - const { todayStart, yesterdayStart, previous7DaysStart, previous30DaysStart } = thresholds; - if (ts >= todayStart) return 'today'; - if (ts >= yesterdayStart) return 'yesterday'; - if (ts >= previous7DaysStart) return 'previous7Days'; - if (ts >= previous30DaysStart) return 'previous30Days'; - return 'older'; -} - -function dateDaysBefore(date: Date, days: number): Date { - const result = new Date(date); - result.setDate(result.getDate() - days); - return result; +function categoryGlyph(category: FileCategory): string { + if (category === 'stylesheet') return '#'; + return kindGlyph(category); } async function filesFromDataTransfer(dataTransfer: DataTransfer): Promise { @@ -2532,16 +1238,16 @@ function readEntryBatch(reader: FileSystemDirectoryReader): Promise 0 ? { pendingFiles: payload.attachments } : {}), + ...(payload.workingDirToken ? { userWorkingDirToken: payload.workingDirToken } : {}), autoSendFirstMessage: true, }); } diff --git a/apps/web/src/components/FileWorkspace.tsx b/apps/web/src/components/FileWorkspace.tsx index 70b3dccd50..0888a75343 100644 --- a/apps/web/src/components/FileWorkspace.tsx +++ b/apps/web/src/components/FileWorkspace.tsx @@ -93,6 +93,12 @@ import type { ChatMessage } from '../types'; interface Props { projectId: string; projectKind: TrackingProjectKind; + // Basename of the project's chosen working directory (e.g. "openclaw"). + // Threaded to DesignFilesPanel as the breadcrumb root label. Undefined for + // default-storage projects. + rootDirName?: string; + // True while a working-dir replace is reindexing; shows a loading state. + reloading?: boolean; /** Absolute on-disk project directory (from GET /api/projects/:id). Used by * the Design Files panel's "copy absolute path" action. */ resolvedDir?: string | null; @@ -323,6 +329,8 @@ const DESIGN_SYSTEM_IMAGE_OR_FONT_EXTENSIONS = /\.(svg|png|jpe?g|gif|webp|avif|i export function FileWorkspace({ projectId, projectKind, + rootDirName, + reloading, resolvedDir, files, liveArtifacts, @@ -2023,12 +2031,11 @@ export function FileWorkspace({ openFile(tabId)} onRenameFile={handleRename} @@ -2057,21 +2064,6 @@ export function FileWorkspace({ fileInputRef.current?.click(); }} onUploadFiles={(picked) => void uploadFiles(picked)} - onCreateFolder={async (name) => { - const folder = await createProjectFolder(projectId, name); - if (folder) { - await refreshProjectFolders(); - } - return folder; - }} - onDeleteFolder={async (folderPath) => { - const ok = await deleteProjectFolder(projectId, folderPath); - if (ok) { - await onRefreshFiles(); - await refreshProjectFolders(); - } - return ok; - }} onPaste={() => { trackFileManagerClick(analytics.track, { page_name: 'file_manager', diff --git a/apps/web/src/components/HomeHero.tsx b/apps/web/src/components/HomeHero.tsx index b9f09a5438..046693330a 100644 --- a/apps/web/src/components/HomeHero.tsx +++ b/apps/web/src/components/HomeHero.tsx @@ -132,6 +132,9 @@ interface Props { contextItemCount: number; error: string | null; showActivePluginChip?: boolean; + workingDir?: string | null; + onPickWorkingDir?: () => void; + onClearWorkingDir?: () => void; onExamplePromptStatusChange?: (info: ExamplePromptInfo | null) => void; } @@ -236,6 +239,9 @@ export const HomeHero = forwardRef(function HomeHero( contextItemCount, error, showActivePluginChip = true, + workingDir = null, + onPickWorkingDir, + onClearWorkingDir, onExamplePromptStatusChange, }, ref, @@ -1203,6 +1209,31 @@ export const HomeHero = forwardRef(function HomeHero( > + {onPickWorkingDir ? ( +
+ + {workingDir ? ( + + ) : null} +
+ ) : null} ([]); const [selectedConnectorContexts, setSelectedConnectorContexts] = useState([]); const [stagedFiles, setStagedFiles] = useState([]); + const [workingDir, setWorkingDir] = useState(null); + // Token paired with `workingDir` when picked through the desktop host's + // native dialog. Spent on the post-creation working-dir POST so the + // daemon's desktop-auth gate accepts the path. Null for web picks. + const [workingDirToken, setWorkingDirToken] = useState(null); const [mcpServers, setMcpServers] = useState([]); const [mcpLoading, setMcpLoading] = useState(true); const [prompt, setPrompt] = useState(''); @@ -923,6 +929,40 @@ export function HomeView({ setStagedFiles((current) => current.filter((_, i) => i !== index)); } + async function handlePickWorkingDir() { + // On desktop the working-dir POST is gated behind a host-minted token, so + // pick through the host bridge to capture { baseDir, token } together. + if (isOpenDesignHostAvailable()) { + const result = await pickHostWorkingDir(); + if (result.ok) { + setWorkingDir(result.baseDir); + setWorkingDirToken(result.token); + return; + } + // The user explicitly cancelled the host picker — respect that and do + // not pop a second dialog. + if ('canceled' in result && result.canceled) return; + // The host is present but could not service the pick (mixed-version + // upgrade where the preload lacks `project.pickWorkingDir`, or a host + // error). We must NOT fall back to openFolderDialog() here: the browser + // dialog yields a raw path with no host-minted token, so the later + // POST /api/projects/:id/working-dir would be rejected by the desktop + // auth gate and surface as a confusing late create-time failure. + // Surface the host error instead and keep the existing working dir. + setError( + `Couldn't open the folder picker (${'reason' in result ? result.reason : 'host unavailable'}). Please update Open Design and try again.`, + ); + return; + } + // Pure web path: no desktop host, so there is no token gate — the raw + // browser folder path is the expected, working input. + const picked = await openFolderDialog(); + if (picked) { + setWorkingDir(picked); + setWorkingDirToken(null); + } + } + function updateActiveInputs(next: Record) { if (!active) return; const normalized = active.mediaSurface @@ -1296,6 +1336,8 @@ export function HomeView({ contextMcpServers, contextConnectors, attachments: stagedFiles, + ...(workingDir ? { workingDir } : {}), + ...(workingDirToken ? { workingDirToken } : {}), conversationMode: sessionMode, ...(() => { if (!examplePromptInfoRef.current) return {}; @@ -1369,6 +1411,12 @@ export function HomeView({ onPickChip={pickChip} contextItemCount={contextItemCount} error={error} + workingDir={workingDir} + onPickWorkingDir={handlePickWorkingDir} + onClearWorkingDir={() => { + setWorkingDir(null); + setWorkingDirToken(null); + }} onExamplePromptStatusChange={handleExamplePromptStatusChange} /> diff --git a/apps/web/src/components/PluginLoopHome.tsx b/apps/web/src/components/PluginLoopHome.tsx index 092dff84d7..39522185ca 100644 --- a/apps/web/src/components/PluginLoopHome.tsx +++ b/apps/web/src/components/PluginLoopHome.tsx @@ -42,6 +42,11 @@ export interface PluginLoopSubmit { projectKind?: 'prototype' | 'deck' | 'template' | 'image' | 'video' | 'audio' | 'other' | null; projectMetadata?: ProjectMetadata | null; workingDir?: string | null; + // Single-use desktop token minted for `workingDir` when the folder was + // chosen through the host's native picker. Spent (not persisted) on the + // post-creation working-dir POST so the daemon's desktop-auth gate accepts + // it. Null/absent for web picks (gate inactive) or no selection. + workingDirToken?: string | null; conversationMode?: ChatSessionMode; // Files staged on Home before the project exists. App uploads them // into the created project's Design Files before the first auto-send. diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index e5083ef7b0..11144a8d70 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -161,6 +161,7 @@ import { ProjectDesignSystemPicker } from './ProjectDesignSystemPicker'; import { PluginDetailsModal } from './PluginDetailsModal'; import { DesignSystemPreviewModal } from './DesignSystemPreviewModal'; import { ChatPane } from './ChatPane'; +import { WorkingDirPill } from './WorkingDirPill'; import type { ChatSendMeta } from './ChatComposer'; import { CritiqueTheaterMount, @@ -768,6 +769,10 @@ export function ProjectView({ const [audioVoiceOptionsError, setAudioVoiceOptionsError] = useState(null); const [artifact, setArtifact] = useState(null); const [filesRefresh, setFilesRefresh] = useState(0); + // True while a working-dir replace is reindexing the new folder. Surfaced + // to the Design Files panel so the file list shows a loading state instead + // of silently sitting on the old tree for the few seconds the scan takes. + const [workingDirReplacing, setWorkingDirReplacing] = useState(false); const [projectFiles, setProjectFiles] = useState([]); const projectFilesRef = useRef([]); const [liveArtifacts, setLiveArtifacts] = useState([]); @@ -5157,6 +5162,14 @@ export function ProjectView({ { + const baseDir = + projectDetail.project?.metadata?.baseDir ?? project.metadata?.baseDir; + return typeof baseDir === 'string' + ? baseDir.split(/[/\\]/).filter(Boolean).pop() + : undefined; + })()} + reloading={workingDirReplacing} resolvedDir={projectDetail.resolvedDir} files={projectFiles} liveArtifacts={liveArtifacts} @@ -5217,6 +5230,26 @@ export function ProjectView({ conversationId={activeConversationId} headerActions={( <> + { + if (updated) onProjectChange(updated); + // The new working dir has a different file tree, so the + // current listing, breadcrumb nav, and open tabs are all + // stale. Refetch files; DesignFilesPanel's self-heal then + // drops the now-unmatched currentDir back to root. + // projectDetail.refresh() repulls resolvedDir so the + // breadcrumb root + pill show the new folder name even on + // the Electron path, which reports no updated project. + setWorkingDirReplacing(true); + refreshFilesAndDesignMd(); + void Promise.all([ + refreshWorkspaceItems(), + projectDetail.refresh(), + ]).finally(() => setWorkingDirReplacing(false)); + }} + /> void; } -function readRecent(): string[] { - try { - const raw = window.localStorage.getItem(RECENT_DIRS_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - return parsed.filter((value): value is string => typeof value === 'string').slice(0, RECENT_DIRS_LIMIT); - } catch { - return []; - } -} - -function pushRecent(dir: string): void { - try { - const prev = readRecent(); - const next = [dir, ...prev.filter((item) => item !== dir)].slice(0, RECENT_DIRS_LIMIT); - window.localStorage.setItem(RECENT_DIRS_KEY, JSON.stringify(next)); - } catch { - // Best-effort local convenience only. - } -} - function shortPath(dir: string): string { - return dir.split('/').filter(Boolean).slice(-1)[0] ?? dir; + return dir.split(/[/\\]/).filter(Boolean).slice(-1)[0] ?? dir; } export function WorkingDirPill({ projectId, resolvedDir: propResolvedDir, onReplaced }: Props) { @@ -56,7 +31,6 @@ export function WorkingDirPill({ projectId, resolvedDir: propResolvedDir, onRepl const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const [recents, setRecents] = useState(() => readRecent()); const [fetchedDir, setFetchedDir] = useState(null); const wrapRef = useRef(null); @@ -100,17 +74,12 @@ export function WorkingDirPill({ projectId, resolvedDir: propResolvedDir, onRepl }; }, [open]); - useEffect(() => { - if (open) setRecents(readRecent()); - }, [open]); - async function applyDir(dir: string) { setError(null); setBusy(true); setOpen(false); try { const result = await replaceProjectWorkingDir(projectId, dir); - pushRecent(result.baseDir); setFetchedDir(result.baseDir); onReplaced?.({ baseDir: result.baseDir, @@ -132,7 +101,6 @@ export function WorkingDirPill({ projectId, resolvedDir: propResolvedDir, onRepl try { const result = await pickAndReplaceHostProjectWorkingDir(projectId); if (result.ok) { - pushRecent(result.baseDir); setFetchedDir(result.baseDir); onReplaced?.({ baseDir: result.baseDir, @@ -191,8 +159,6 @@ export function WorkingDirPill({ projectId, resolvedDir: propResolvedDir, onRepl } } - const showRecents = !isOpenDesignHostAvailable(); - return (
{t('workingDirPicker.replace')} - {showRecents && recents.filter((item) => item !== resolvedDir).length > 0 ? ( - <> -
-
{t('workingDirPicker.recent')}
- {recents - .filter((item) => item !== resolvedDir) - .map((dir) => ( - - ))} - - ) : null} {error ? ( <>
diff --git a/apps/web/src/i18n/locales/ar.ts b/apps/web/src/i18n/locales/ar.ts index 6dc9936453..7f12bc8213 100644 --- a/apps/web/src/i18n/locales/ar.ts +++ b/apps/web/src/i18n/locales/ar.ts @@ -32,7 +32,7 @@ export const ar: Dict = { 'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.", 'workingDirPicker.openFailed': "Could not show this folder", 'workingDirPicker.showInFileManager': "Show in file manager", - 'workingDirPicker.replace': "Clear and replace directory…", + 'workingDirPicker.replace': "Replace directory…", 'workingDirPicker.recent': "Recent directories", 'handoff.toTarget': 'Hand off to {target}', 'handoff.action': 'Hand off', @@ -1099,6 +1099,13 @@ export const ar: Dict = { 'designFiles.next': 'التالي', 'designFiles.jumpToPage': 'انتقل إلى الصفحة', 'designFiles.pageInfo': '{start}–{end} من {total}', + 'designFiles.sectionFolders': 'Folders', + 'designFiles.sectionStylesheets': 'Stylesheets', + 'designFiles.sectionDocuments': 'Documents', + 'designFiles.kindStylesheet': 'Stylesheet', + 'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}', + 'designFiles.usefulInfoLabel': 'Useful info', + 'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.', 'quickSwitcher.placeholder': 'فتح ملف…', 'quickSwitcher.empty': 'لا توجد ملفات في هذا المشروع', 'quickSwitcher.noMatches': 'لا توجد نتائج', diff --git a/apps/web/src/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index 3d7af33b9a..a93d6132b5 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -32,7 +32,7 @@ export const de: Dict = { 'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.", 'workingDirPicker.openFailed': "Could not show this folder", 'workingDirPicker.showInFileManager': "Show in file manager", - 'workingDirPicker.replace': "Clear and replace directory…", + 'workingDirPicker.replace': "Replace directory…", 'workingDirPicker.recent': "Recent directories", 'handoff.toTarget': 'Hand off to {target}', 'handoff.action': 'Hand off', @@ -987,6 +987,13 @@ export const de: Dict = { 'designFiles.next': 'Weiter', 'designFiles.jumpToPage': 'Gehe zu Seite', 'designFiles.pageInfo': '{start}–{end} von {total}', + 'designFiles.sectionFolders': 'Folders', + 'designFiles.sectionStylesheets': 'Stylesheets', + 'designFiles.sectionDocuments': 'Documents', + 'designFiles.kindStylesheet': 'Stylesheet', + 'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}', + 'designFiles.usefulInfoLabel': 'Useful info', + 'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.', 'quickSwitcher.placeholder': 'Datei öffnen…', 'quickSwitcher.empty': 'Keine Dateien in diesem Projekt', 'quickSwitcher.noMatches': 'Keine Treffer', diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 24e9033226..0193174b99 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -558,7 +558,7 @@ export const en: Dict = { 'workingDirPicker.openUnavailable': 'Open this project in the desktop app to show the folder.', 'workingDirPicker.openFailed': 'Could not show this folder', 'workingDirPicker.showInFileManager': 'Show in file manager', - 'workingDirPicker.replace': 'Clear and replace directory…', + 'workingDirPicker.replace': 'Replace directory…', 'workingDirPicker.recent': 'Recent directories', 'handoff.toTarget': 'Hand off to {target}', 'handoff.openInTarget': 'Open in {target}', @@ -1908,6 +1908,13 @@ export const en: Dict = { 'designFiles.next': 'Next', 'designFiles.jumpToPage': 'Go to page', 'designFiles.pageInfo': '{start}–{end} of {total}', + 'designFiles.sectionFolders': 'Folders', + 'designFiles.sectionStylesheets': 'Stylesheets', + 'designFiles.sectionDocuments': 'Documents', + 'designFiles.kindStylesheet': 'Stylesheet', + 'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}', + 'designFiles.usefulInfoLabel': 'Useful info', + 'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.', 'quickSwitcher.placeholder': 'Open tab or file…', 'quickSwitcher.empty': 'No files in this project', 'quickSwitcher.noMatches': 'No matches', diff --git a/apps/web/src/i18n/locales/es-ES.ts b/apps/web/src/i18n/locales/es-ES.ts index 5a5e752c91..051a342d15 100644 --- a/apps/web/src/i18n/locales/es-ES.ts +++ b/apps/web/src/i18n/locales/es-ES.ts @@ -32,7 +32,7 @@ export const esES: Dict = { 'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.", 'workingDirPicker.openFailed': "Could not show this folder", 'workingDirPicker.showInFileManager': "Show in file manager", - 'workingDirPicker.replace': "Clear and replace directory…", + 'workingDirPicker.replace': "Replace directory…", 'workingDirPicker.recent': "Recent directories", 'handoff.toTarget': 'Hand off to {target}', 'handoff.action': 'Hand off', @@ -988,6 +988,13 @@ export const esES: Dict = { 'designFiles.next': 'Siguiente', 'designFiles.jumpToPage': 'Ir a la página', 'designFiles.pageInfo': '{start}–{end} de {total}', + 'designFiles.sectionFolders': 'Folders', + 'designFiles.sectionStylesheets': 'Stylesheets', + 'designFiles.sectionDocuments': 'Documents', + 'designFiles.kindStylesheet': 'Stylesheet', + 'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}', + 'designFiles.usefulInfoLabel': 'Useful info', + 'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.', 'quickSwitcher.placeholder': 'Abrir archivo…', 'quickSwitcher.empty': 'No hay archivos en este proyecto', 'quickSwitcher.noMatches': 'Sin resultados', diff --git a/apps/web/src/i18n/locales/fa.ts b/apps/web/src/i18n/locales/fa.ts index df3ca998cd..771cc2e3bd 100644 --- a/apps/web/src/i18n/locales/fa.ts +++ b/apps/web/src/i18n/locales/fa.ts @@ -32,7 +32,7 @@ export const fa: Dict = { 'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.", 'workingDirPicker.openFailed': "Could not show this folder", 'workingDirPicker.showInFileManager': "Show in file manager", - 'workingDirPicker.replace': "Clear and replace directory…", + 'workingDirPicker.replace': "Replace directory…", 'workingDirPicker.recent': "Recent directories", 'handoff.toTarget': 'Hand off to {target}', 'handoff.action': 'Hand off', @@ -1123,6 +1123,13 @@ export const fa: Dict = { 'designFiles.next': 'بعدی', 'designFiles.jumpToPage': 'برو به صفحه', 'designFiles.pageInfo': '{start}–{end} از {total}', + 'designFiles.sectionFolders': 'Folders', + 'designFiles.sectionStylesheets': 'Stylesheets', + 'designFiles.sectionDocuments': 'Documents', + 'designFiles.kindStylesheet': 'Stylesheet', + 'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}', + 'designFiles.usefulInfoLabel': 'Useful info', + 'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.', 'quickSwitcher.placeholder': 'باز کردن فایل…', 'quickSwitcher.empty': 'هیچ فایلی در این پروژه نیست', 'quickSwitcher.noMatches': 'بدون نتیجه', diff --git a/apps/web/src/i18n/locales/fr.ts b/apps/web/src/i18n/locales/fr.ts index dff29fa920..b7a0b8423a 100644 --- a/apps/web/src/i18n/locales/fr.ts +++ b/apps/web/src/i18n/locales/fr.ts @@ -505,7 +505,7 @@ export const fr: Dict = { 'workingDirPicker.openUnavailable': 'Ouvrez ce projet dans l’app desktop pour afficher le dossier.', 'workingDirPicker.openFailed': 'Impossible d’afficher ce dossier', 'workingDirPicker.showInFileManager': 'Afficher dans le gestionnaire de fichiers', - 'workingDirPicker.replace': 'Effacer et remplacer le dossier…', + 'workingDirPicker.replace': 'Remplacer le dossier…', 'workingDirPicker.recent': 'Dossiers récents', 'handoff.toTarget': 'Transférer vers {target}', 'handoff.action': 'Transférer', @@ -1632,6 +1632,13 @@ export const fr: Dict = { 'designFiles.next': 'Suivant', 'designFiles.jumpToPage': 'Aller à la page', 'designFiles.pageInfo': '{start}–{end} sur {total}', + 'designFiles.sectionFolders': 'Folders', + 'designFiles.sectionStylesheets': 'Stylesheets', + 'designFiles.sectionDocuments': 'Documents', + 'designFiles.kindStylesheet': 'Stylesheet', + 'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}', + 'designFiles.usefulInfoLabel': 'Useful info', + 'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.', 'quickSwitcher.placeholder': 'Ouvrir un fichier…', 'quickSwitcher.empty': 'Aucun fichier dans ce projet', 'quickSwitcher.noMatches': 'Aucun résultat', diff --git a/apps/web/src/i18n/locales/hu.ts b/apps/web/src/i18n/locales/hu.ts index b30f03fe5a..957a09ad0e 100644 --- a/apps/web/src/i18n/locales/hu.ts +++ b/apps/web/src/i18n/locales/hu.ts @@ -32,7 +32,7 @@ export const hu: Dict = { 'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.", 'workingDirPicker.openFailed': "Could not show this folder", 'workingDirPicker.showInFileManager': "Show in file manager", - 'workingDirPicker.replace': "Clear and replace directory…", + 'workingDirPicker.replace': "Replace directory…", 'workingDirPicker.recent': "Recent directories", 'handoff.toTarget': 'Hand off to {target}', 'handoff.action': 'Hand off', @@ -1099,6 +1099,13 @@ export const hu: Dict = { 'designFiles.next': 'Következő', 'designFiles.jumpToPage': 'Ugrás oldalra', 'designFiles.pageInfo': '{start}–{end} / {total}', + 'designFiles.sectionFolders': 'Folders', + 'designFiles.sectionStylesheets': 'Stylesheets', + 'designFiles.sectionDocuments': 'Documents', + 'designFiles.kindStylesheet': 'Stylesheet', + 'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}', + 'designFiles.usefulInfoLabel': 'Useful info', + 'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.', 'quickSwitcher.placeholder': 'Fájl megnyitása…', 'quickSwitcher.empty': 'Nincsenek fájlok ebben a projektben', 'quickSwitcher.noMatches': 'Nincs találat', diff --git a/apps/web/src/i18n/locales/id.ts b/apps/web/src/i18n/locales/id.ts index d5d9e158b7..afb8178009 100644 --- a/apps/web/src/i18n/locales/id.ts +++ b/apps/web/src/i18n/locales/id.ts @@ -32,7 +32,7 @@ export const id: Dict = { 'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.", 'workingDirPicker.openFailed': "Could not show this folder", 'workingDirPicker.showInFileManager': "Show in file manager", - 'workingDirPicker.replace': "Clear and replace directory…", + 'workingDirPicker.replace': "Replace directory…", 'workingDirPicker.recent': "Recent directories", 'handoff.toTarget': 'Hand off to {target}', 'handoff.action': 'Hand off', @@ -1211,6 +1211,13 @@ export const id: Dict = { 'designFiles.next': 'Berikutnya', 'designFiles.jumpToPage': 'Lompat ke halaman', 'designFiles.pageInfo': '{start}–{end} dari {total}', + 'designFiles.sectionFolders': 'Folders', + 'designFiles.sectionStylesheets': 'Stylesheets', + 'designFiles.sectionDocuments': 'Documents', + 'designFiles.kindStylesheet': 'Stylesheet', + 'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}', + 'designFiles.usefulInfoLabel': 'Useful info', + 'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.', 'quickSwitcher.placeholder': 'Buka file...', 'quickSwitcher.empty': 'Tidak ada file di proyek ini', diff --git a/apps/web/src/i18n/locales/it.ts b/apps/web/src/i18n/locales/it.ts index 3723a29a06..8233dc8c48 100644 --- a/apps/web/src/i18n/locales/it.ts +++ b/apps/web/src/i18n/locales/it.ts @@ -32,7 +32,7 @@ export const it: Dict = { 'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.", 'workingDirPicker.openFailed': "Could not show this folder", 'workingDirPicker.showInFileManager': "Show in file manager", - 'workingDirPicker.replace': "Clear and replace directory…", + 'workingDirPicker.replace': "Replace directory…", 'workingDirPicker.recent': "Recent directories", 'handoff.toTarget': 'Hand off to {target}', 'handoff.action': 'Hand off', @@ -1004,6 +1004,13 @@ export const it: Dict = { 'designFiles.next': 'Successivo', 'designFiles.jumpToPage': 'Vai alla pagina', 'designFiles.pageInfo': '{start}–{end} su {total}', + 'designFiles.sectionFolders': 'Folders', + 'designFiles.sectionStylesheets': 'Stylesheets', + 'designFiles.sectionDocuments': 'Documents', + 'designFiles.kindStylesheet': 'Stylesheet', + 'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}', + 'designFiles.usefulInfoLabel': 'Useful info', + 'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.', 'quickSwitcher.placeholder': 'Apri un file…', 'quickSwitcher.empty': 'Nessun file in questo progetto', 'quickSwitcher.noMatches': 'Nessun risultato', diff --git a/apps/web/src/i18n/locales/ja.ts b/apps/web/src/i18n/locales/ja.ts index 11e042f4ce..65eaf305ce 100644 --- a/apps/web/src/i18n/locales/ja.ts +++ b/apps/web/src/i18n/locales/ja.ts @@ -32,7 +32,7 @@ export const ja: Dict = { 'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.", 'workingDirPicker.openFailed': "Could not show this folder", 'workingDirPicker.showInFileManager': "Show in file manager", - 'workingDirPicker.replace': "Clear and replace directory…", + 'workingDirPicker.replace': "Replace directory…", 'workingDirPicker.recent': "Recent directories", 'handoff.toTarget': 'Hand off to {target}', 'handoff.action': 'Hand off', @@ -986,6 +986,13 @@ export const ja: Dict = { 'designFiles.next': '次へ', 'designFiles.jumpToPage': 'ページにジャンプ', 'designFiles.pageInfo': '{start}–{end} / {total}', + 'designFiles.sectionFolders': 'Folders', + 'designFiles.sectionStylesheets': 'Stylesheets', + 'designFiles.sectionDocuments': 'Documents', + 'designFiles.kindStylesheet': 'Stylesheet', + 'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}', + 'designFiles.usefulInfoLabel': 'Useful info', + 'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.', 'quickSwitcher.placeholder': 'ファイルを開く…', 'quickSwitcher.empty': 'このプロジェクトにファイルがありません', 'quickSwitcher.noMatches': '一致なし', diff --git a/apps/web/src/i18n/locales/ko.ts b/apps/web/src/i18n/locales/ko.ts index 84a0383f61..4f1ba3669a 100644 --- a/apps/web/src/i18n/locales/ko.ts +++ b/apps/web/src/i18n/locales/ko.ts @@ -32,7 +32,7 @@ export const ko: Dict = { 'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.", 'workingDirPicker.openFailed': "Could not show this folder", 'workingDirPicker.showInFileManager': "Show in file manager", - 'workingDirPicker.replace': "Clear and replace directory…", + 'workingDirPicker.replace': "Replace directory…", 'workingDirPicker.recent': "Recent directories", 'handoff.toTarget': 'Hand off to {target}', 'handoff.action': 'Hand off', @@ -1099,6 +1099,13 @@ export const ko: Dict = { 'designFiles.next': '다음', 'designFiles.jumpToPage': '페이지로 이동', 'designFiles.pageInfo': '{start}–{end} / {total}', + 'designFiles.sectionFolders': 'Folders', + 'designFiles.sectionStylesheets': 'Stylesheets', + 'designFiles.sectionDocuments': 'Documents', + 'designFiles.kindStylesheet': 'Stylesheet', + 'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}', + 'designFiles.usefulInfoLabel': 'Useful info', + 'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.', 'quickSwitcher.placeholder': '파일 열기…', 'quickSwitcher.empty': '이 프로젝트에 파일이 없습니다', 'quickSwitcher.noMatches': '일치 항목 없음', diff --git a/apps/web/src/i18n/locales/pl.ts b/apps/web/src/i18n/locales/pl.ts index b7fad0908b..65d17b8bd1 100644 --- a/apps/web/src/i18n/locales/pl.ts +++ b/apps/web/src/i18n/locales/pl.ts @@ -32,7 +32,7 @@ export const pl: Dict = { 'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.", 'workingDirPicker.openFailed': "Could not show this folder", 'workingDirPicker.showInFileManager': "Show in file manager", - 'workingDirPicker.replace': "Clear and replace directory…", + 'workingDirPicker.replace': "Replace directory…", 'workingDirPicker.recent': "Recent directories", 'handoff.toTarget': 'Hand off to {target}', 'handoff.action': 'Hand off', @@ -1099,6 +1099,13 @@ export const pl: Dict = { 'designFiles.next': 'Następna', 'designFiles.jumpToPage': 'Przejdź do strony', 'designFiles.pageInfo': '{start}–{end} z {total}', + 'designFiles.sectionFolders': 'Folders', + 'designFiles.sectionStylesheets': 'Stylesheets', + 'designFiles.sectionDocuments': 'Documents', + 'designFiles.kindStylesheet': 'Stylesheet', + 'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}', + 'designFiles.usefulInfoLabel': 'Useful info', + 'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.', 'quickSwitcher.placeholder': 'Otwórz plik…', 'quickSwitcher.empty': 'Brak plików w tym projekcie', 'quickSwitcher.noMatches': 'Brak wyników', diff --git a/apps/web/src/i18n/locales/pt-BR.ts b/apps/web/src/i18n/locales/pt-BR.ts index 258155457d..94bce250f0 100644 --- a/apps/web/src/i18n/locales/pt-BR.ts +++ b/apps/web/src/i18n/locales/pt-BR.ts @@ -32,7 +32,7 @@ export const ptBR: Dict = { 'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.", 'workingDirPicker.openFailed': "Could not show this folder", 'workingDirPicker.showInFileManager': "Show in file manager", - 'workingDirPicker.replace': "Clear and replace directory…", + 'workingDirPicker.replace': "Replace directory…", 'workingDirPicker.recent': "Recent directories", 'handoff.toTarget': 'Hand off to {target}', 'handoff.action': 'Hand off', @@ -1122,6 +1122,13 @@ export const ptBR: Dict = { 'designFiles.next': 'Próximo', 'designFiles.jumpToPage': 'Ir para a página', 'designFiles.pageInfo': '{start}–{end} de {total}', + 'designFiles.sectionFolders': 'Folders', + 'designFiles.sectionStylesheets': 'Stylesheets', + 'designFiles.sectionDocuments': 'Documents', + 'designFiles.kindStylesheet': 'Stylesheet', + 'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}', + 'designFiles.usefulInfoLabel': 'Useful info', + 'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.', 'quickSwitcher.placeholder': 'Abrir arquivo…', 'quickSwitcher.empty': 'Nenhum arquivo neste projeto', 'quickSwitcher.noMatches': 'Sem resultados', diff --git a/apps/web/src/i18n/locales/ru.ts b/apps/web/src/i18n/locales/ru.ts index 322dbc5d3b..6c7c8974af 100644 --- a/apps/web/src/i18n/locales/ru.ts +++ b/apps/web/src/i18n/locales/ru.ts @@ -32,7 +32,7 @@ export const ru: Dict = { 'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.", 'workingDirPicker.openFailed': "Could not show this folder", 'workingDirPicker.showInFileManager': "Show in file manager", - 'workingDirPicker.replace': "Clear and replace directory…", + 'workingDirPicker.replace': "Replace directory…", 'workingDirPicker.recent': "Recent directories", 'handoff.toTarget': 'Hand off to {target}', 'handoff.action': 'Hand off', @@ -1122,6 +1122,13 @@ export const ru: Dict = { 'designFiles.next': 'Вперёд', 'designFiles.jumpToPage': 'Перейти на страницу', 'designFiles.pageInfo': '{start}–{end} из {total}', + 'designFiles.sectionFolders': 'Folders', + 'designFiles.sectionStylesheets': 'Stylesheets', + 'designFiles.sectionDocuments': 'Documents', + 'designFiles.kindStylesheet': 'Stylesheet', + 'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}', + 'designFiles.usefulInfoLabel': 'Useful info', + 'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.', 'quickSwitcher.placeholder': 'Открыть файл…', 'quickSwitcher.empty': 'В проекте нет файлов', 'quickSwitcher.noMatches': 'Нет совпадений', diff --git a/apps/web/src/i18n/locales/th.ts b/apps/web/src/i18n/locales/th.ts index f4434ebcf2..5f3b0487e8 100644 --- a/apps/web/src/i18n/locales/th.ts +++ b/apps/web/src/i18n/locales/th.ts @@ -32,7 +32,7 @@ export const th: Dict = { 'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.", 'workingDirPicker.openFailed': "Could not show this folder", 'workingDirPicker.showInFileManager': "Show in file manager", - 'workingDirPicker.replace': "Clear and replace directory…", + 'workingDirPicker.replace': "Replace directory…", 'workingDirPicker.recent': "Recent directories", 'handoff.toTarget': 'Hand off to {target}', 'handoff.action': 'Hand off', @@ -1037,6 +1037,13 @@ export const th: Dict = { 'designFiles.next': 'ถัดไป', 'designFiles.jumpToPage': 'ไปหน้าที่', 'designFiles.pageInfo': '{start}–{end} รวม {total}', + 'designFiles.sectionFolders': 'Folders', + 'designFiles.sectionStylesheets': 'Stylesheets', + 'designFiles.sectionDocuments': 'Documents', + 'designFiles.kindStylesheet': 'Stylesheet', + 'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}', + 'designFiles.usefulInfoLabel': 'Useful info', + 'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.', 'quickSwitcher.placeholder': 'ค้นหาและเปิด…', 'quickSwitcher.empty': 'ไร้ไฟล์ที่หาได้', 'quickSwitcher.noMatches': 'ไม่เจอรายการที่ตรง', diff --git a/apps/web/src/i18n/locales/tr.ts b/apps/web/src/i18n/locales/tr.ts index b6f365014b..111b3c08a2 100644 --- a/apps/web/src/i18n/locales/tr.ts +++ b/apps/web/src/i18n/locales/tr.ts @@ -32,7 +32,7 @@ export const tr: Dict = { 'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.", 'workingDirPicker.openFailed': "Could not show this folder", 'workingDirPicker.showInFileManager': "Show in file manager", - 'workingDirPicker.replace': "Clear and replace directory…", + 'workingDirPicker.replace': "Replace directory…", 'workingDirPicker.recent': "Recent directories", 'handoff.toTarget': 'Hand off to {target}', 'handoff.action': 'Hand off', @@ -1086,6 +1086,13 @@ export const tr: Dict = { 'designFiles.next': 'Sonraki', 'designFiles.jumpToPage': 'Sayfaya git', 'designFiles.pageInfo': '{start}–{end} / {total}', + 'designFiles.sectionFolders': 'Folders', + 'designFiles.sectionStylesheets': 'Stylesheets', + 'designFiles.sectionDocuments': 'Documents', + 'designFiles.kindStylesheet': 'Stylesheet', + 'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}', + 'designFiles.usefulInfoLabel': 'Useful info', + 'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.', 'quickSwitcher.placeholder': 'Dosya aç…', 'quickSwitcher.empty': 'Bu projede dosya yok', 'quickSwitcher.noMatches': 'Eşleşme yok', diff --git a/apps/web/src/i18n/locales/uk.ts b/apps/web/src/i18n/locales/uk.ts index 6dc0cf7628..4d0ddda1d8 100644 --- a/apps/web/src/i18n/locales/uk.ts +++ b/apps/web/src/i18n/locales/uk.ts @@ -32,7 +32,7 @@ export const uk: Dict = { 'workingDirPicker.openUnavailable': "Open this project in the desktop app to show the folder.", 'workingDirPicker.openFailed': "Could not show this folder", 'workingDirPicker.showInFileManager': "Show in file manager", - 'workingDirPicker.replace': "Clear and replace directory…", + 'workingDirPicker.replace': "Replace directory…", 'workingDirPicker.recent': "Recent directories", 'handoff.toTarget': 'Hand off to {target}', 'handoff.action': 'Hand off', @@ -1123,6 +1123,13 @@ export const uk: Dict = { 'designFiles.next': 'Далі', 'designFiles.jumpToPage': 'Перейти на сторінку', 'designFiles.pageInfo': '{start}–{end} із {total}', + 'designFiles.sectionFolders': 'Folders', + 'designFiles.sectionStylesheets': 'Stylesheets', + 'designFiles.sectionDocuments': 'Documents', + 'designFiles.kindStylesheet': 'Stylesheet', + 'designFiles.modifiedExt': 'Modified {time} · {size} · {ext}', + 'designFiles.usefulInfoLabel': 'Useful info', + 'designFiles.usefulInfoTip': 'Double-click any file to open it in a full tab.', 'liveArtifact.refresh.button': 'Оновити', 'liveArtifact.refresh.buttonTitle': 'Оновити live-артефакт', 'liveArtifact.refresh.loadingTitle': 'Оновлення live-артефакту…', diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index 21bd1fd5fd..7cdf35eb98 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -557,7 +557,7 @@ export const zhCN: Dict = { 'workingDirPicker.openUnavailable': '请在桌面版中打开此项目以显示文件夹。', 'workingDirPicker.openFailed': '无法显示此文件夹', 'workingDirPicker.showInFileManager': '在文件管理器中显示', - 'workingDirPicker.replace': '清空并替换目录…', + 'workingDirPicker.replace': '替换目录…', 'workingDirPicker.recent': '最近使用的目录', 'handoff.toTarget': '交付给 {target}', 'handoff.openInTarget': '在 {target} 中打开', @@ -1881,6 +1881,13 @@ export const zhCN: Dict = { 'designFiles.next': '下一页', 'designFiles.jumpToPage': '跳转到页面', 'designFiles.pageInfo': '{start}–{end} / {total}', + 'designFiles.sectionFolders': '文件夹', + 'designFiles.sectionStylesheets': '样式表', + 'designFiles.sectionDocuments': '文档', + 'designFiles.kindStylesheet': '样式表', + 'designFiles.modifiedExt': '修改于 {time} · {size} · {ext}', + 'designFiles.usefulInfoLabel': '使用提示', + 'designFiles.usefulInfoTip': '双击任意文件可在独立标签页中打开。', 'quickSwitcher.placeholder': '打开标签页或文件…', 'quickSwitcher.empty': '此项目中没有文件', 'quickSwitcher.noMatches': '无匹配项', diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index f80aee64bb..3722a33f9a 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -32,7 +32,7 @@ export const zhTW: Dict = { 'workingDirPicker.openUnavailable': "請在桌面版中開啟此專案以顯示資料夾。", 'workingDirPicker.openFailed': "無法顯示此資料夾", 'workingDirPicker.showInFileManager': "在檔案管理器中顯示", - 'workingDirPicker.replace': "清空並替換目錄…", + 'workingDirPicker.replace': "替換目錄…", 'workingDirPicker.recent': "最近使用的目錄", 'handoff.toTarget': '交付給 {target}', 'handoff.openInTarget': '在 {target} 中開啟', @@ -1475,6 +1475,13 @@ export const zhTW: Dict = { 'designFiles.next': '下一頁', 'designFiles.jumpToPage': '跳轉到頁面', 'designFiles.pageInfo': '{start}–{end} / {total}', + 'designFiles.sectionFolders': '資料夾', + 'designFiles.sectionStylesheets': '樣式表', + 'designFiles.sectionDocuments': '文件', + 'designFiles.kindStylesheet': '樣式表', + 'designFiles.modifiedExt': '修改於 {time} · {size} · {ext}', + 'designFiles.usefulInfoLabel': '使用提示', + 'designFiles.usefulInfoTip': '雙擊任意檔案即可在獨立分頁中開啟。', 'quickSwitcher.placeholder': '開啟分頁或檔案…', 'quickSwitcher.empty': '此專案中沒有檔案', 'quickSwitcher.noMatches': '無符合項目', diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index dfef944499..59e7c2b0cf 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -2245,6 +2245,13 @@ export interface Dict { 'designFiles.next': string; 'designFiles.jumpToPage': string; 'designFiles.pageInfo': string; + 'designFiles.sectionFolders': string; + 'designFiles.sectionStylesheets': string; + 'designFiles.sectionDocuments': string; + 'designFiles.kindStylesheet': string; + 'designFiles.modifiedExt': string; + 'designFiles.usefulInfoLabel': string; + 'designFiles.usefulInfoTip': string; 'quickSwitcher.placeholder': string; 'quickSwitcher.empty': string; 'quickSwitcher.noMatches': string; diff --git a/apps/web/src/styles/home/home-hero.css b/apps/web/src/styles/home/home-hero.css index 179567255c..cd58bf94c2 100644 --- a/apps/web/src/styles/home/home-hero.css +++ b/apps/web/src/styles/home/home-hero.css @@ -768,6 +768,7 @@ min-width: 0; flex: 1 1 auto; flex-wrap: nowrap; + overflow: visible; } .home-hero__tool { appearance: none; @@ -811,17 +812,16 @@ position: relative; display: inline-flex; align-items: center; - min-width: 0; + flex-shrink: 0; } .home-hero__working-dir { appearance: none; display: inline-flex; align-items: center; - gap: 6px; + gap: 4px; height: 32px; - max-width: 220px; - min-width: 0; - padding: 0 12px; + max-width: 180px; + padding: 0 8px; border: 1px solid var(--border); border-radius: 999px; background: var(--bg-panel); @@ -943,10 +943,11 @@ height: 32px; display: inline-flex; align-items: center; - gap: 6px; + gap: 4px; max-width: 220px; min-width: 0; - padding: 0 12px; + flex-shrink: 1; + padding: 0 8px; border: 1px solid var(--border); border-radius: 999px; background: var(--bg-panel); @@ -988,7 +989,7 @@ outline: none; } .home-hero__footer-option input { - width: 150px; + width: 100px; } .home-hero__footer-option--select { padding-right: 11px; diff --git a/apps/web/src/styles/viewer/routines.css b/apps/web/src/styles/viewer/routines.css index 9c86292ecd..5217dc8086 100644 --- a/apps/web/src/styles/viewer/routines.css +++ b/apps/web/src/styles/viewer/routines.css @@ -1555,246 +1555,6 @@ box-shadow: none; } -.app .df-panel, -.app .df-main { - background: var(--bg-panel); -} -.app .df-panel:not(.no-preview) { - grid-template-columns: minmax(320px, 1fr) minmax(320px, 360px); -} -.app .df-main { - border-right-color: var(--border); -} -.app .df-body { - padding: 16px 20px 20px; - background: var(--bg-panel); -} -.app .df-controls-row { - margin: 0 0 14px; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 10px 14px; -} -.app .df-controls-primary { - grid-template-columns: 31px minmax(220px, 340px) minmax(0, auto); - gap: 10px; -} -.app .df-controls-toggles { - gap: 8px; -} -.app .df-refresh-control { - width: 31px; - height: 31px; - border: 1px solid var(--border); - border-radius: 999px; - background: var(--bg-panel); - box-shadow: none; - flex: 0 0 auto; -} -.app .df-refresh-control:hover:not(:disabled) { - background: var(--bg-subtle); - border-color: var(--border-strong); -} -.app .df-controls-row .df-actions { - margin-left: 0; - gap: 8px; -} -.app .df-controls-row .df-actions button { - min-height: 31px; - padding: 0 12px; - border: 1px solid var(--border); - border-radius: 999px; - background: var(--bg-panel); - box-shadow: none; -} -.app .df-controls-row .df-actions button:hover:not(:disabled) { - background: var(--bg-subtle); - border-color: var(--border-strong); -} -.app .df-group-toggle { - align-self: flex-start; - margin: 0; - padding: 2px; - border: 1px solid var(--border); - border-radius: 10px; - background: var(--bg-subtle); - box-shadow: none; -} -.app .df-group-toggle > span { - padding: 0 8px; -} -.app .df-group-toggle button { - min-height: 26px; - padding: 2px 11px; - border-radius: 8px; -} -.app .df-group-toggle button.active { - background: var(--bg-panel); - box-shadow: none; -} -@media (max-width: 1180px) { - .app .df-controls-row { - grid-template-columns: minmax(0, 1fr); - } - .app .df-controls-primary { - grid-template-columns: 31px minmax(180px, 1fr); - } -} -@media (max-width: 720px) { - .app .df-controls-primary { - grid-template-columns: 31px minmax(0, 1fr); - } -} -.app .df-pagination { - gap: 8px; - padding: 0 0 12px; - color: var(--text-muted); -} -.app .df-pagination-start { - border-bottom: 1px solid var(--border-soft); -} -.app .df-pagination-center { - justify-content: center; - padding: 14px 0 10px; -} -.app .df-pagination select, -.app .df-page-btn { - min-height: 28px; - border-radius: 8px; - background-color: var(--bg-panel); - box-shadow: none; -} -.app .df-select-all { - border-radius: 7px; -} -.app .df-table { - width: 100%; - padding: 0; - border-collapse: collapse; - border-spacing: 0; - table-layout: fixed; -} -.app .df-table thead { - border-bottom: 1px solid var(--border-soft); -} -.app .df-th-check, -.app .df-cell-check { - width: 28px; - padding-left: 8px; -} -.app .df-th-icon, -.app .df-cell-icon { - width: 40px; -} -.app .df-th-kind { - width: 128px; -} -.app .df-th-time { - width: 126px; -} -.app .df-th-menu, -.app .df-cell-menu { - width: 34px; - padding-right: 6px; -} -.app .df-panel:not(.no-preview) .df-th-kind, -.app .df-panel:not(.no-preview) .df-cell-kind, -.app .df-panel:not(.no-preview) .df-th-time, -.app .df-panel:not(.no-preview) .df-cell-time { - display: none; -} -.app .df-panel:not(.no-preview) .df-table { - table-layout: auto; -} -.app .df-panel:not(.no-preview) .df-th-name, -.app .df-panel:not(.no-preview) .df-cell-name { - width: auto; -} -.app .df-panel:not(.no-preview) .df-cell-name { - min-width: 0; -} -.app .df-panel:not(.no-preview) .df-row-name-btn, -.app .df-panel:not(.no-preview) .df-row-name-wrap, -.app .df-panel:not(.no-preview) .df-row-name { - width: 100%; - min-width: 0; - max-width: 100%; -} -.app .df-panel:not(.no-preview) .df-row-name { - display: block; -} -.app .df-th-sortable { - padding: 10px 8px 8px; - font-size: 10px; -} -.app .df-section-row td { - padding: 14px 8px 7px; - border-top: 0; -} -.app .df-section-label { - padding: 0; - font-size: 10px; -} -.app .df-cell-name, -.app .df-cell-kind, -.app .df-cell-time { - padding-top: 8px; - padding-bottom: 8px; -} -.app .df-cell-name { - padding-right: 14px; -} -.app .df-file-row + .df-file-row .df-cell-check, -.app .df-file-row + .df-file-row .df-cell-icon, -.app .df-file-row + .df-file-row .df-cell-name, -.app .df-file-row + .df-file-row .df-cell-kind, -.app .df-file-row + .df-file-row .df-cell-time, -.app .df-file-row + .df-file-row .df-cell-menu { - border-top: 1px solid var(--border-soft); -} -.app .df-file-row:hover { - background: color-mix(in srgb, var(--bg-subtle) 62%, transparent); -} -.app .df-row-icon { - width: 30px; - height: 30px; - border-radius: 8px; -} -.app .df-row-name { - font-size: 13px; - font-weight: 560; - color: var(--text-strong); -} -.app .df-row-name-wrap { - gap: 0; -} -.app .df-row-size, -.app .df-row-sub, -.app .df-row-meta, -.app .df-cell-kind, -.app .df-cell-time, -.app .df-kind-label { - font-size: 12px; -} -.app .df-cell-time, -.app .df-page-info { - color: var(--text-soft); -} -.app .df-drop { - margin: 18px 0 0; - padding: 18px; - border-radius: 10px; - background: var(--bg-panel); -} -.app .df-drop .label { - font-size: 10.5px; -} -.app .df-drop .desc { - font-size: 12px; - max-width: 48ch; -} - /* -- Hand-off button in ChatPane header (paseo-style open-in-editor) -- */ .app .handoff-wrap { position: relative; diff --git a/apps/web/src/styles/workspace/design-files.css b/apps/web/src/styles/workspace/design-files.css index 2c74eafa7f..a5ecbaf1e5 100644 --- a/apps/web/src/styles/workspace/design-files.css +++ b/apps/web/src/styles/workspace/design-files.css @@ -1,14 +1,24 @@ /* -------- Design Files panel (full right pane) ---------------------- */ .df-panel { + position: relative; flex: 1; display: grid; - grid-template-columns: minmax(0, 1fr) minmax(320px, 380px); + grid-template-columns: minmax(0, 1fr) minmax(0, 380px); min-height: 0; background: var(--bg); } .df-panel.no-preview { grid-template-columns: minmax(0, 1fr); } +.df-reloading-overlay { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--bg) 70%, transparent); + backdrop-filter: blur(1px); +} .df-main { - container: design-files-main / inline-size; display: flex; flex-direction: column; min-height: 0; @@ -20,12 +30,7 @@ flex: 1; min-height: 0; overflow-y: auto; - /* Reserve the scrollbar gutter so the toolbar/list (which live inside this - scroll container) never shift horizontally when a classic scrollbar - appears or disappears — e.g. on Windows/Linux, or when content crosses the - scroll threshold. No-op under macOS overlay scrollbars. */ - scrollbar-gutter: stable; - padding: 12px 0 0; + padding: 0; display: flex; flex-direction: column; } @@ -34,7 +39,7 @@ align-items: center; flex-wrap: wrap; padding: 0 20px 6px; - font-size: 12px; + font-size: 14px; color: var(--text-muted); } .df-breadcrumb-btn { @@ -43,1745 +48,152 @@ color: var(--text-muted); padding: 2px 4px; border-radius: 3px; - font-size: 12px; + font-size: 14px; transition: color 120ms cubic-bezier(0.23, 1, 0.32, 1), background 120ms cubic-bezier(0.23, 1, 0.32, 1); } .df-breadcrumb-btn:hover { color: var(--text); background: var(--bg-subtle); } .df-breadcrumb-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } .df-breadcrumb-segment { display: inline-flex; align-items: center; } -.df-breadcrumb-sep { padding: 0 2px; color: var(--text-faint); font-size: 11px; user-select: none; } -.df-breadcrumb-current { padding: 2px 4px; color: var(--text); font-weight: 500; font-size: 12px; } -.df-dir-row td { cursor: pointer; } -.df-controls-row { +.df-breadcrumb-sep { padding: 0 2px; color: var(--text-faint); font-size: 12px; user-select: none; } +.df-breadcrumb-current { padding: 2px 4px; color: var(--text-strong); font-weight: 600; font-size: 14px; } +/* Single-line top toolbar: left = up + refresh + breadcrumbs, right = file actions. */ +.df-topbar { margin: 0 20px 8px; display: flex; - flex-direction: column; - align-items: stretch; - gap: 10px; -} -.df-controls-topline { - min-width: 0; - display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.df-controls-primary { +.df-topbar-left { + display: flex; + align-items: center; + min-width: 0; flex: 1 1 auto; +} +.df-topbar-left .df-breadcrumbs { + padding: 0; + margin: 0; min-width: 0; - display: flex; +} +.df-topbar-right { + display: inline-flex; align-items: center; - gap: 8px; + flex-shrink: 0; } -.df-controls-toggles { - min-height: 34px; - min-width: 0; - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} -.df-controls-toggles:empty { - display: none; -} -.df-controls-actions { - flex: 0 1 auto; - min-width: 0; - display: flex; - align-items: center; - justify-content: flex-end; - margin-left: auto; -} -.df-controls-row .df-actions { - margin-left: 0; +.df-actions { display: inline-flex; align-items: center; justify-content: flex-end; - gap: 8px; + gap: 4px; flex-wrap: nowrap; - min-width: 0; + flex-shrink: 0; } -.df-controls-row .df-actions button { +.df-actions button { background: transparent; - border: 1px solid var(--border); + border: 1px solid transparent; color: var(--text-muted); - min-height: 36px; - padding: 6px 14px; - font-size: 12.5px; - border-radius: 999px; + padding: 6px 10px; + font-size: 14px; + border-radius: var(--radius-sm); display: inline-flex; align-items: center; - justify-content: center; gap: 6px; - white-space: nowrap; } -.df-controls-row .df-actions button:hover:not(:disabled) { +.df-actions button:hover:not(:disabled) { background: var(--bg-subtle); color: var(--text); - border-color: var(--border-strong); } -.df-controls-row .df-actions button.danger { color: var(--red); } -.df-controls-row .df-actions button.danger:hover:not(:disabled) { +.df-actions button.danger { color: var(--red); } +.df-actions button.danger:hover:not(:disabled) { background: var(--red-bg); color: var(--red); } -.df-refresh-control { - flex: 0 0 30px; - width: 28px; - height: 28px; - padding: 0; - border: 1px solid transparent; +/* Upload error banner. */ +.df-upload-banner { + margin: 0 20px 8px; + padding: 8px 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border: 1px solid var(--red-border, var(--border)); border-radius: var(--radius-sm); + background: var(--red-bg); + color: var(--red); + font-size: 12px; +} +.df-upload-banner button { + flex-shrink: 0; background: transparent; - color: var(--text-muted); - display: inline-flex; - align-items: center; - justify-content: center; -} -.df-refresh-control:hover:not(:disabled) { - background: var(--bg-subtle); - color: var(--text); -} -.df-search-control { - position: relative; - flex: 0 0 34px; - min-width: 0; - width: 34px; - min-height: 34px; - display: inline-flex; - align-items: center; - color: var(--text-muted); - transition: - flex-basis 160ms cubic-bezier(0.23, 1, 0.32, 1), - width 160ms cubic-bezier(0.23, 1, 0.32, 1); -} -.df-search-control.is-expanded { - flex: 0 1 430px; - width: min(430px, 44vw); - gap: 8px; - padding: 0 8px 0 10px; - border: 1px solid var(--border); - border-radius: 10px; - background: var(--bg-panel); - box-shadow: var(--shadow-xs); -} -.df-search-icon-trigger { - position: relative; - width: 34px; - height: 34px; - padding: 0; - border: 1px solid transparent; - border-radius: 10px; - background: transparent; - color: var(--text-muted); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; -} -.df-search-icon-trigger:hover, -.df-search-icon-trigger:focus-visible { - background: var(--bg-subtle); - color: var(--text); -} -.df-search-icon-trigger:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} -.df-search-active-dot { - position: absolute; - right: 7px; - top: 7px; - width: 6px; - height: 6px; - border-radius: 999px; - background: var(--accent); - box-shadow: 0 0 0 2px var(--bg); -} -.df-search-control input { - min-width: 0; - width: 100%; - border: 0; - outline: 0; - background: transparent; - color: var(--text); - font: inherit; - font-size: 12.5px; -} -.df-search-control:focus-within { - border-color: var(--border-strong); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 13%, transparent); -} -.df-search-clear { - width: 22px; - height: 22px; - padding: 0; - border: 0; - border-radius: 6px; - background: transparent; - color: var(--text-soft); - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - cursor: pointer; -} -.df-search-clear:hover, -.df-search-clear:focus-visible { - background: var(--bg-subtle); - color: var(--text); -} -.df-search-clear:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 1px; -} -.df-group-toggle { - margin: 0; - display: inline-flex; - align-items: center; - gap: 4px; - padding: 3px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--bg-panel); - color: var(--text-muted); - font-size: 11.5px; -} -.df-group-toggle > span { - padding: 0 6px 0 4px; - color: var(--text-faint); -} -.df-group-toggle button { - min-height: 24px; + border: 1px solid currentColor; + color: inherit; padding: 3px 9px; - border: 1px solid transparent; - background: transparent; - color: var(--text-muted); font-size: 11.5px; -} -.df-group-toggle button:hover { - background: var(--bg-subtle); - color: var(--text); -} -.df-group-toggle button.active { - background: var(--bg-subtle); - border-color: var(--border); - color: var(--text); -} - -@media (max-width: 1180px) { - .df-controls-topline { - flex-wrap: wrap; - } - .df-search-control.is-expanded { - flex-basis: 360px; - width: min(360px, calc(100vw - 96px)); - } - .df-controls-actions { - justify-content: flex-end; - } -} - -@media (max-width: 720px) { - .df-controls-row { - margin-inline: 14px; - gap: 8px; - } - .df-controls-primary { - gap: 8px; - } - .df-search-control.is-expanded { - flex: 1 1 calc(100% - 42px); - width: auto; - } - .df-controls-toggles, - .df-kind-filter, - .df-kind-filter-trigger { - width: 100%; - } - .df-controls-toggles { - flex: 1 1 100%; - min-height: 0; - } - .df-controls-actions { - margin-left: 0; - flex: 1 1 360px; - width: auto; - justify-content: flex-end; - } - .df-controls-row .df-actions { - width: 100%; - justify-content: stretch; - flex-wrap: wrap; - } - .df-controls-row .df-actions button { - flex: 1 1 142px; - } -} - -@container design-files-main (max-width: 1180px) { - .df-controls-topline { - flex-wrap: wrap; - } - .df-search-control.is-expanded { - flex-basis: min(360px, calc(100cqw - 96px)); - width: min(360px, calc(100cqw - 96px)); - } - .df-controls-actions { - justify-content: flex-end; - } -} - -@container design-files-main (max-width: 720px) { - .df-controls-row { - margin-inline: 14px; - gap: 8px; - } - .df-controls-primary { - flex: 0 0 auto; - align-self: flex-start; - gap: 8px; - } - .df-search-control.is-expanded { - flex: 1 1 calc(100% - 42px); - width: auto; - } - .df-controls-toggles, - .df-kind-filter, - .df-kind-filter-trigger { - width: 100%; - } - .df-controls-toggles { - flex: 1 1 100%; - min-height: 0; - } - .df-controls-actions { - margin-left: 0; - flex: 1 1 360px; - width: auto; - justify-content: flex-end; - } - .df-controls-row .df-actions { - width: 100%; - justify-content: stretch; - flex-wrap: wrap; - } - .df-controls-row .df-actions button { - flex: 1 1 142px; - } -} - -@container design-files-main (max-width: 560px) { - .df-controls-topline { - align-items: stretch; - flex-direction: column; - } - .df-controls-primary { - width: auto; - } - .df-controls-actions { - flex-basis: auto; - width: 100%; - justify-content: stretch; - } - .df-search-control.is-expanded { - flex-basis: auto; - } - .df-group-toggle { - width: 100%; - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - align-items: stretch; - } - .df-group-toggle > span { - grid-column: 1 / -1; - min-width: 0; - padding: 2px 6px; - } - .df-group-toggle button { - min-width: 0; - } - .df-kind-filter-popover { - width: min(280px, calc(100cqw - 28px)); - max-width: calc(100cqw - 28px); - } -} - -/* Filter-by-kind dropdown. Lives in the .df-controls-row alongside the - group-by toggle. Trigger is a button with a count badge when active; - popover is a single-column checkbox list of available kinds. */ -.df-kind-filter { - position: relative; - display: inline-flex; -} -.df-kind-filter-trigger { - display: inline-flex; - align-items: center; - gap: 6px; - min-height: 30px; - padding: 3px 10px; - border: 1px solid var(--border); border-radius: var(--radius-sm); - background: var(--bg-panel); - color: var(--text-muted); - font-size: 11.5px; cursor: pointer; } -.df-kind-filter-trigger:hover { - background: var(--bg-subtle); - color: var(--text); -} -.df-kind-filter-trigger.active { - background: var(--bg-subtle); - border-color: var(--selected, var(--border)); - color: var(--text); -} -.df-kind-filter-trigger-label { - white-space: nowrap; -} -.df-kind-filter-count { - min-width: 18px; - height: 18px; - padding: 0 5px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 9px; - background: var(--selected, var(--accent, var(--text))); - color: var(--bg-panel); - font-size: 10.5px; - font-weight: 600; - line-height: 1; -} -.df-kind-filter-popover { - position: absolute; - top: calc(100% + 6px); - left: 0; - width: max(220px, 100%); - max-height: min(360px, calc(100vh - 96px)); - padding: 6px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--bg-panel); - box-shadow: var(--shadow-lg); - z-index: 130; - display: flex; - flex-direction: column; - gap: 4px; - overflow: hidden; -} -.df-kind-filter-header { + +/* Batch-selection action bar. Shown above the list when any row is selected. */ +.df-batch-bar { + margin: 0 20px 8px; + padding: 7px 12px; display: flex; align-items: center; justify-content: space-between; - gap: 8px; - padding: 4px 8px 6px; - border-bottom: 1px solid var(--border); - color: var(--text-faint); - font-size: 10.5px; - letter-spacing: 0.06em; - text-transform: uppercase; -} -.df-kind-filter-clear { - border: 0; - background: transparent; - color: var(--text-muted); - font-size: 11px; - text-transform: none; - letter-spacing: 0; - cursor: pointer; - padding: 2px 4px; - border-radius: 4px; -} -.df-kind-filter-clear:hover { - background: var(--bg-subtle); - color: var(--text); -} -.df-kind-filter-list { - list-style: none; - margin: 0; - padding: 0; - overflow-y: auto; - display: flex; - flex-direction: column; -} - -/* ============================================================ - Design browser workspace module — a polished in-app browser: - chrome bar + address omnibox, a reference-board start page, - and the live webview/iframe content surface. - ============================================================ */ -.design-browser { - container: design-browser / inline-size; - isolation: isolate; - position: relative; - flex: 1 1 auto; - width: 100%; - height: 100%; - max-width: 100%; - min-height: 0; - display: flex; - flex-direction: column; - background: var(--bg); - color: var(--text); - overflow: hidden; -} - -/* ---- Chrome bar ---------------------------------------------------------- */ -.db-chrome { - position: relative; - display: grid; - grid-template-columns: minmax(0, auto) minmax(180px, 1fr) max-content; - align-items: center; - gap: 10px; - min-width: 0; - padding: 9px 14px; - border-bottom: 1px solid color-mix(in srgb, var(--border) 80%, transparent); - background: var(--bg-panel); - box-shadow: 0 1px 0 color-mix(in srgb, var(--border) 45%, transparent); - z-index: 500; -} -.db-nav, -.db-actions { - display: inline-flex; - align-items: center; - gap: 2px; - min-width: 0; - max-width: 100%; -} -.db-actions { - position: relative; - justify-content: flex-end; - gap: 4px; - flex-wrap: nowrap; - overflow: visible; -} -.db-action-item { - flex: 0 0 auto; -} -.db-tooltip-anchor { - position: relative; - display: inline-flex; -} -.db-tooltip-anchor:not(.od-tooltip)::after { - content: attr(data-tooltip); - position: absolute; - top: calc(100% + 8px); - left: 50%; - z-index: 300; - max-width: 220px; - padding: 5px 9px; - border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); - border-radius: 7px; - background: color-mix(in srgb, var(--text-strong) 92%, var(--text)); - color: var(--bg-panel); - box-shadow: 0 8px 24px rgba(28, 27, 26, 0.18); - font-size: 11px; - font-weight: 500; - line-height: 1.25; - white-space: nowrap; - pointer-events: none; - opacity: 0; - transform: translate(-50%, -3px); - transition: opacity 130ms cubic-bezier(0.23, 1, 0.32, 1), transform 130ms cubic-bezier(0.23, 1, 0.32, 1); -} -.db-tooltip-anchor:not(.od-tooltip):hover::after, -.db-tooltip-anchor:not(.od-tooltip):focus-within::after { - opacity: 1; - transform: translate(-50%, 0); -} -.db-icon-btn { - width: 32px; - height: 32px; - padding: 0; - border: 1px solid transparent; - border-radius: 8px; - background: transparent; - color: var(--text-muted); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: - background 130ms cubic-bezier(0.23, 1, 0.32, 1), - color 130ms cubic-bezier(0.23, 1, 0.32, 1), - transform 130ms cubic-bezier(0.23, 1, 0.32, 1); -} -.db-icon-btn:hover:not(:disabled) { - background: color-mix(in srgb, var(--bg-subtle) 72%, transparent); - color: var(--text); -} -.db-icon-btn.is-active { - border-color: color-mix(in srgb, var(--accent) 36%, var(--border)); + gap: 12px; + border: 1px solid var(--accent-soft); + border-radius: var(--radius-sm); background: var(--accent-tint); - color: var(--accent-strong); + animation: df-batch-enter 200ms cubic-bezier(0.23, 1, 0.32, 1); } -.db-icon-btn:active:not(:disabled) { - transform: scale(0.93); -} -.db-icon-btn:disabled { - cursor: default; - opacity: 0.34; -} -.db-icon-btn:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} -.db-icon-btn.is-spinning svg { - animation: db-spin 900ms linear infinite; -} -@keyframes db-spin { - to { transform: rotate(360deg); } -} -.db-viewport-switcher { - position: relative; - display: inline-flex; -} -.db-viewport-switcher .db-icon-btn { - width: auto; - min-width: 76px; - gap: 4px; - padding: 0 8px; -} -.db-viewport-label { - max-width: 58px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 11.5px; - font-weight: 600; -} -.db-viewport-menu { - position: absolute; - top: calc(100% + 8px); - left: 0; - z-index: 720; - width: 156px; - padding: 6px; - border: 1px solid color-mix(in srgb, var(--border) 85%, transparent); - border-radius: 12px; - background: var(--bg-panel); - box-shadow: var(--shadow-lg); - animation: db-pop 160ms cubic-bezier(0.23, 1, 0.32, 1); -} -.db-viewport-menu button { - width: 100%; - min-height: 34px; - display: grid; - grid-template-columns: minmax(0, 1fr) 16px; - align-items: center; - gap: 8px; - border: 0; - border-radius: 8px; - background: transparent; - color: var(--text); - padding: 6px 8px; - text-align: left; - font-size: 12px; - cursor: pointer; -} -.db-viewport-menu button:hover, -.db-viewport-menu button.active { - background: var(--bg-subtle); -} -/* ---- Address omnibox ----------------------------------------------------- */ -.db-address-form { - position: relative; - height: 38px; - min-width: 0; - border: 1px solid color-mix(in srgb, var(--border) 90%, transparent); - border-radius: 11px; - background: var(--bg-panel); - color: var(--text-muted); - display: flex; - align-items: center; - gap: 9px; - padding: 0 12px; - overflow: visible; - transition: - background 150ms cubic-bezier(0.23, 1, 0.32, 1), - border-color 150ms cubic-bezier(0.23, 1, 0.32, 1), - box-shadow 150ms cubic-bezier(0.23, 1, 0.32, 1); -} -.db-address-form:hover { - border-color: var(--border-strong); - background: var(--bg-panel); -} -.db-address-form:focus-within { - border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); - background: var(--bg-panel); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); -} -.db-site-icon { - width: 16px; - height: 16px; - flex: 0 0 auto; - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--text-soft); - overflow: hidden; -} -.db-site-icon img { - width: 100%; - height: 100%; - display: block; - border-radius: 3px; - object-fit: contain; -} -.db-address-site-icon { - width: 17px; - height: 17px; -} -.db-address-field { - flex: 1 1 auto; - min-width: 0; - height: 100%; - position: relative; - display: flex; - align-items: center; -} -.db-address-form input { - position: relative; - z-index: 1; - width: 100%; - min-width: 0; - height: 100%; - border: 0; - outline: 0; - background: transparent; - color: var(--text); - font: inherit; - font-size: 13px; - letter-spacing: 0; -} -.db-address-form input:focus { - border: 0; - outline: none; - box-shadow: none; -} -.db-address-form input::placeholder { - color: var(--text-faint); -} -.db-address-display { - position: absolute; - inset: 0; - z-index: 0; - min-width: 0; - display: flex; - align-items: center; - gap: 7px; - overflow: hidden; - pointer-events: none; - font-size: 13px; - letter-spacing: 0; - white-space: nowrap; -} -.db-address-url, -.db-address-title { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; -} -.db-address-url { - color: var(--text); - flex: 0 1 auto; -} -.db-address-separator, -.db-address-title { - color: var(--text-faint); -} -.db-address-title { - flex: 1 1 auto; -} -.db-suggestions { - position: absolute; - top: calc(100% + 8px); - left: 0; - right: 0; - max-height: min(520px, calc(100vh - 136px)); - padding: 7px; - border: 1px solid color-mix(in srgb, var(--border) 85%, transparent); - border-radius: 13px; - background: var(--bg-panel); - box-shadow: var(--shadow-lg); - overflow-y: auto; - z-index: 220; - animation: db-pop 160ms cubic-bezier(0.23, 1, 0.32, 1); -} -@keyframes db-pop { - from { opacity: 0; transform: translateY(-6px) scale(0.99); } - to { opacity: 1; transform: translateY(0) scale(1); } -} -.db-suggestions button { - width: 100%; - min-height: 44px; - display: grid; - grid-template-columns: 28px minmax(0, 1fr) auto; - align-items: center; - gap: 10px; - border: 0; - border-radius: 9px; - background: transparent; - color: var(--text); - padding: 7px 9px; - cursor: pointer; - text-align: left; - transition: background 120ms cubic-bezier(0.23, 1, 0.32, 1); -} -.db-suggestions button:hover { - background: color-mix(in srgb, var(--bg-subtle) 70%, transparent); -} -.db-suggestion-icon { - width: 28px; - height: 28px; - border-radius: 8px; - background: var(--bg-subtle); - color: var(--text-muted); - display: inline-flex; - align-items: center; - justify-content: center; -} -.db-suggestion-copy { - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} -.db-suggestion-copy span, -.db-suggestion-copy small { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.db-suggestion-copy span { - font-size: 12.5px; - font-weight: 600; -} -.db-suggestion-copy small { - color: var(--text-muted); - font-size: 11px; -} -.db-suggestion-type { - color: var(--text-soft); - font-size: 10px; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - padding: 3px 8px; - border-radius: 999px; - background: color-mix(in srgb, var(--bg-subtle) 68%, transparent); -} -/* ---- Overflow menu ------------------------------------------------------- */ -.db-menu { - position: absolute; - top: calc(100% + 8px); - right: 0; - width: 252px; - padding: 7px; - border: 1px solid color-mix(in srgb, var(--border) 85%, transparent); - border-radius: 13px; - background: var(--bg-panel); - box-shadow: var(--shadow-lg); - z-index: 720; - animation: db-pop 160ms cubic-bezier(0.23, 1, 0.32, 1); -} -.db-menu button { - width: 100%; - min-height: 36px; - display: grid; - grid-template-columns: 22px minmax(0, 1fr); - align-items: center; - gap: 9px; - border: 0; - border-radius: 9px; - background: transparent; - color: var(--text); - padding: 7px 9px; - text-align: left; - cursor: pointer; - font-size: 12.5px; - transition: background 120ms cubic-bezier(0.23, 1, 0.32, 1), color 120ms cubic-bezier(0.23, 1, 0.32, 1); -} -.db-menu button svg { - color: var(--text-soft); - transition: color 120ms cubic-bezier(0.23, 1, 0.32, 1); -} -.db-menu button:hover:not(:disabled) { - background: color-mix(in srgb, var(--bg-subtle) 70%, transparent); -} -.db-menu button:hover:not(:disabled) svg { - color: var(--text); -} -.db-menu button:disabled { - color: var(--text-faint); - cursor: default; -} -.db-menu button:disabled svg { - color: var(--text-faint); -} -.db-menu-separator { - display: block; - height: 1px; - margin: 6px 4px; - background: color-mix(in srgb, var(--border) 70%, transparent); -} -.db-browser-use-menu { - width: min(430px, calc(100vw - 28px)); - max-height: min(620px, calc(100vh - 112px)); - display: flex; - flex-direction: column; - gap: 8px; - overflow: hidden; -} -.db-browser-use-head { - display: flex; - flex-direction: column; - gap: 3px; - padding: 5px 6px 7px; - border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); -} -.db-browser-use-head strong { - color: var(--text); - font-size: 12.5px; - line-height: 1.25; -} -.db-browser-use-head small { - color: var(--text-muted); - font-size: 11.5px; - line-height: 1.35; -} -.db-browser-use-search { - display: grid; - grid-template-columns: 16px minmax(0, 1fr) auto; - align-items: center; - gap: 7px; - min-height: 34px; - padding: 0 8px; - border: 1px solid color-mix(in srgb, var(--border) 78%, transparent); - border-radius: 8px; - background: color-mix(in srgb, var(--bg) 90%, transparent); - color: var(--text-soft); -} -.db-browser-use-search input { - min-width: 0; - border: 0; - outline: 0; - background: transparent; - color: var(--text); - font: inherit; - font-size: 12px; - line-height: 1.2; -} -.db-browser-use-search input::placeholder { - color: var(--text-faint); -} -.db-browser-use-search span { - min-width: 20px; - padding: 2px 6px; - border-radius: 999px; - background: color-mix(in srgb, var(--bg-subtle) 82%, transparent); - color: var(--text-muted); - font-size: 10.5px; - line-height: 1.2; - text-align: center; -} -.db-browser-use-list { - min-height: 0; - overflow-y: auto; - padding-right: 2px; -} -.db-browser-use-section + .db-browser-use-section { - margin-top: 8px; -} -.db-browser-use-section-title { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 4px 7px; - color: var(--text-soft); - font-size: 10.5px; - font-weight: 700; - letter-spacing: 0.04em; - text-transform: uppercase; -} -.db-menu button.db-browser-use-action { - min-height: 46px; - grid-template-columns: 20px minmax(0, 1fr) minmax(56px, 118px); - align-items: center; - gap: 9px; -} -.db-browser-use-action-copy { - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} -.db-browser-use-action-copy > span, -.db-browser-use-action-copy small, -.db-browser-use-action-input { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.db-browser-use-action-copy > span { - font-size: 12.5px; - font-weight: 650; -} -.db-browser-use-action-copy small { - color: var(--text-muted); - font-size: 11px; - line-height: 1.25; -} -.db-browser-use-action-input { - justify-self: end; - max-width: 118px; - padding: 3px 6px; - border-radius: 999px; - background: color-mix(in srgb, var(--bg-subtle) 72%, transparent); - color: var(--text-soft); - font-size: 10.5px; - line-height: 1.2; -} -.db-browser-use-empty { - padding: 18px 8px; - color: var(--text-muted); - font-size: 12px; - text-align: center; -} - -/* ---- Status toast -------------------------------------------------------- */ -.db-status { - position: absolute; - left: 50%; - bottom: 22px; - z-index: 240; - display: inline-flex; - align-items: center; - max-width: calc(100% - 32px); - padding: 9px 17px; - border: 1px solid color-mix(in srgb, var(--border) 60%, transparent); - border-radius: 999px; - background: color-mix(in srgb, var(--text-strong) 92%, var(--text)); - color: var(--bg-panel); - box-shadow: 0 14px 34px rgba(28, 27, 26, 0.24); - font-size: 12.5px; - font-weight: 500; - letter-spacing: -0.01em; - white-space: nowrap; - pointer-events: none; - transform: translateX(-50%); - animation: db-toast-in 200ms cubic-bezier(0.23, 1, 0.32, 1); -} -@keyframes db-toast-in { - from { opacity: 0; transform: translate(-50%, 10px); } - to { opacity: 1; transform: translate(-50%, 0); } -} - -/* ---- Content surface ----------------------------------------------------- */ -.db-content { - flex: 1 1 auto; - min-height: 0; - min-width: 0; - position: relative; - z-index: 0; - display: flex; - background: var(--bg); - overflow: hidden; -} -.db-content-viewport-tablet, -.db-content-viewport-mobile { - background: - linear-gradient(45deg, color-mix(in srgb, var(--border) 26%, transparent) 25%, transparent 25%), - linear-gradient(-45deg, color-mix(in srgb, var(--border) 26%, transparent) 25%, transparent 25%), - linear-gradient(45deg, transparent 75%, color-mix(in srgb, var(--border) 26%, transparent) 75%), - linear-gradient(-45deg, transparent 75%, color-mix(in srgb, var(--border) 26%, transparent) 75%); - background-color: color-mix(in srgb, var(--bg) 92%, var(--text) 8%); - background-size: 18px 18px; - background-position: 0 0, 0 9px, 9px -9px, -9px 0; -} -.db-viewport-frame { - position: absolute; - inset: 0; - display: flex; - min-width: 0; - min-height: 0; - overflow: hidden; - background: #fff; -} -.db-viewport-tablet, -.db-viewport-mobile { - inset: 16px auto auto 50%; - width: min(var(--db-viewport-width), calc(100% - 32px)); - height: min(var(--db-viewport-height), calc(100% - 32px)); - border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); - border-radius: 18px; - box-shadow: 0 16px 44px rgba(28, 27, 26, 0.16); - transform: translateX(-50%); -} -.db-viewport-mobile { - border-radius: 24px; -} -.db-webview, -.db-fallback, -.db-fallback iframe { - flex: 1 1 auto; - min-width: 0; - min-height: 0; - width: 100%; - height: 100%; - border: 0; -} -.db-fallback { - position: relative; - display: flex; - background: var(--bg); -} -.db-fallback iframe { - background: #fff; -} -.db-comment-layer { - position: absolute; - inset: 0; - z-index: 20; - pointer-events: none; -} -.db-comment-marker { - position: absolute; - min-width: 24px; - min-height: 24px; - padding: 0; - border: 2px solid #1677ff; - border-radius: 8px; - background: rgba(22, 119, 255, 0.12); - color: #fff; - pointer-events: auto; - cursor: pointer; -} -.db-comment-marker span { - position: absolute; - top: -13px; - right: -13px; - min-width: 22px; - height: 22px; - display: inline-flex; - align-items: center; - justify-content: center; - border: 2px solid #fff; - border-radius: 999px; - background: #1677ff; - box-shadow: 0 4px 12px rgba(22, 119, 255, 0.32); - font-size: 11px; - font-weight: 700; -} -.db-comment-marker.active { - border-color: #da6138; - background: rgba(218, 97, 56, 0.14); -} -.db-comment-marker.active span { - background: #da6138; - box-shadow: 0 4px 12px rgba(218, 97, 56, 0.34); -} -.design-browser .comment-popover, -.db-comment-popover { - z-index: 240; -} -.db-comment-popover textarea { - width: 100%; -} -.design-browser .db-inspect-panel { - z-index: 360; - isolation: isolate; -} -.db-inspect-text { - min-height: 96px; - resize: vertical; - padding: 7px 8px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--bg); - color: var(--text); - font: inherit; - font-size: 12px; -} -.db-tool-hint { - position: absolute; - left: 50%; - top: 18px; - z-index: 130; - max-width: calc(100% - 32px); - padding: 8px 13px; - border-radius: 999px; - background: rgba(20, 20, 20, 0.88); - color: #fff; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18); - font-size: 12.5px; - line-height: 1.3; - pointer-events: none; - transform: translateX(-50%); -} - -@container design-browser (max-width: 920px) { - .db-chrome { - grid-template-columns: minmax(0, auto) minmax(150px, 1fr) max-content; - gap: 8px; - padding-inline: 10px; - } - .db-action-secondary { - display: none; - } - .db-address-title { - display: none; - } -} - -@container design-browser (max-width: 760px) { - .db-chrome { - grid-template-columns: minmax(0, auto) minmax(120px, 1fr) max-content; - gap: 6px; - padding-inline: 8px; - } - .db-viewport-switcher .db-icon-btn { - min-width: 46px; - padding-inline: 6px; - } - .db-viewport-label { - display: none; - } - .db-action-mark, - .db-action-edit { - display: none; - } -} - -@container design-browser (max-width: 620px) { - .db-chrome { - grid-template-columns: minmax(0, auto) minmax(88px, 1fr) auto; - gap: 4px; - } - .db-nav { - gap: 0; - } - .db-nav .db-tooltip-anchor:nth-child(2) { - display: none; - } - .db-actions { - gap: 0; - } - .db-action-comment, - .db-action-tune { - display: none; - } - .db-icon-btn { - width: 30px; - height: 30px; - } - .db-address-form { - height: 34px; - padding-inline: 9px; - } -} -/* ---- Reference-board start page ------------------------------------------ */ -.db-start { - /* PreviewDrawOverlay wraps the browser content in a `position: absolute; - inset: 0` div that is NOT a flex container, so `flex: 1 1 auto` can't bound - this element's height (only the webview/fallback siblings fill it, via - `height: 100%`). Without a bound the reference grid grows to its content - height and the `overflow-y: auto` below has nothing to scroll — and the - sticky `.db-reference-toolbar` never pins. Fill the overlay the same way - the webview does so the board scrolls and the toolbar sticks to the top. */ - flex: 1 1 auto; - height: 100%; - min-width: 0; - min-height: 0; - max-width: 100%; - display: flex; - flex-direction: column; - overflow-x: hidden; - overflow-y: auto; - overscroll-behavior: contain; - background: - radial-gradient(120% 78% at 50% -12%, color-mix(in srgb, var(--accent) 7%, transparent) 0%, transparent 56%), - linear-gradient(180deg, color-mix(in srgb, var(--bg-panel) 55%, var(--bg)) 0%, var(--bg) 28%); -} -.db-start-hero { - flex: 0 0 auto; - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 20px; - align-items: flex-end; - max-width: 1180px; - width: 100%; - margin: 0 auto; - padding: 24px 30px 16px; - animation: db-rise 320ms cubic-bezier(0.23, 1, 0.32, 1) both; -} -.db-start-hero-copy { - min-width: 0; -} -@keyframes db-rise { - from { opacity: 0; transform: translateY(8px); } +@keyframes df-batch-enter { + from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } -.db-kicker { - display: inline-flex; - align-items: center; - gap: 8px; - color: var(--accent); - font-size: 11px; - font-weight: 650; - letter-spacing: 0.14em; - text-transform: uppercase; -} -.db-kicker::before { - content: ""; - width: 6px; - height: 6px; - border-radius: 999px; - background: var(--accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); -} -.db-start h2 { - max-width: 720px; - margin: 11px 0 0; - color: var(--text-strong); - font-size: 32px; - font-weight: 700; - line-height: 1.04; - letter-spacing: -0.022em; -} -.db-start-sub { - max-width: 560px; - margin: 10px 0 0; - color: var(--text-muted); - font-size: 13.5px; - line-height: 1.5; -} -/* ---- Filter toolbar (category chips + search) ---------------------------- */ -.db-reference-toolbar { - flex: 0 0 auto; - position: sticky; - top: 0; - z-index: 2; - display: flex; - align-items: center; - gap: 14px; - flex-wrap: wrap; - max-width: 1180px; - width: 100%; - margin: 0 auto; - padding: 12px 30px; - border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent); - background: color-mix(in srgb, var(--bg) 94%, var(--bg-panel)); - box-shadow: 0 1px 0 color-mix(in srgb, var(--bg-panel) 75%, transparent); - animation: db-rise 320ms cubic-bezier(0.23, 1, 0.32, 1) both; -} -.db-reference-chips { - flex: 1 1 360px; - min-width: 0; - display: flex; - align-items: center; - gap: 8px; - overflow-x: auto; - scrollbar-width: none; - padding-bottom: 2px; -} -.db-reference-chips::-webkit-scrollbar { - display: none; -} -.db-reference-chip { - flex: 0 0 auto; - display: inline-flex; - align-items: center; - gap: 7px; - border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); - border-radius: 999px; - background: var(--bg-panel); - color: var(--text-muted); - padding: 6px 13px; +.df-batch-count { font-size: 12.5px; - font-weight: 550; - white-space: nowrap; - cursor: pointer; - transition: - background 140ms cubic-bezier(0.23, 1, 0.32, 1), - border-color 140ms cubic-bezier(0.23, 1, 0.32, 1), - color 140ms cubic-bezier(0.23, 1, 0.32, 1); -} -.db-reference-chip:hover { - border-color: var(--border-strong); - color: var(--text); -} -.db-reference-chip.is-active { - border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); - background: var(--accent-tint); - color: var(--accent-strong); -} -.db-reference-chip-count { - min-width: 18px; - text-align: center; - border-radius: 999px; - padding: 1px 6px; - font-size: 11px; - font-weight: 600; - color: var(--text-soft); - background: color-mix(in srgb, var(--border) 40%, transparent); -} -.db-reference-chip.is-active .db-reference-chip-count { - color: var(--accent-strong); - background: color-mix(in srgb, var(--accent) 18%, transparent); -} -.db-reference-search { - flex: 0 0 226px; - display: inline-flex; - align-items: center; - gap: 8px; - height: 34px; - padding: 0 10px; - border: 1px solid var(--border); - border-radius: 10px; - background: var(--bg-panel); - transition: border-color 140ms cubic-bezier(0.23, 1, 0.32, 1); -} -.db-reference-search:focus-within { - border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); -} -.db-reference-search-icon { - display: inline-flex; - color: var(--text-soft); -} -.db-reference-search input { - flex: 1 1 auto; - min-width: 0; - border: 0; - background: transparent; - color: var(--text); - font-size: 13px; - outline: none; -} -.db-reference-search input::placeholder { - color: var(--text-soft); -} -.db-reference-search input::-webkit-search-cancel-button { - -webkit-appearance: none; - appearance: none; -} -.db-reference-search-clear { - display: inline-flex; - align-items: center; - justify-content: center; - border: 0; - border-radius: 6px; - padding: 2px; - background: transparent; - color: var(--text-soft); - cursor: pointer; - transition: - background 140ms cubic-bezier(0.23, 1, 0.32, 1), - color 140ms cubic-bezier(0.23, 1, 0.32, 1); -} -.db-reference-search-clear:hover { - background: var(--bg-subtle); - color: var(--text); -} -/* ---- Scrolling reference board ------------------------------------------- */ -.db-reference-board { - flex: 0 0 auto; - min-height: 0; - overflow: visible; - max-width: 1180px; - width: 100%; - margin: 0 auto; - padding: 22px 30px 44px; - display: flex; - flex-direction: column; - gap: 30px; -} -.db-reference-empty { - flex: 1 1 auto; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 14px; - padding: 60px 30px; - text-align: center; -} -.db-reference-empty-title { - margin: 0; - color: var(--text-muted); - font-size: 14px; -} -.db-reference-empty-action { - border: 1px solid var(--border); - border-radius: 9px; - background: var(--bg-panel); - color: var(--text); - padding: 7px 14px; - font-size: 12.5px; - font-weight: 550; - cursor: pointer; - transition: - background 140ms cubic-bezier(0.23, 1, 0.32, 1), - border-color 140ms cubic-bezier(0.23, 1, 0.32, 1); -} -.db-reference-empty-action:hover { - background: var(--bg-subtle); - border-color: var(--border-strong); -} -.db-reference-group { - min-width: 0; - animation: db-rise 360ms cubic-bezier(0.23, 1, 0.32, 1) both; -} -.db-reference-group h3 { - display: flex; - align-items: center; - gap: 10px; - margin: 0 0 13px; - color: var(--text-soft); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.1em; - text-transform: uppercase; -} -.db-reference-group-count { - flex: 0 0 auto; - letter-spacing: normal; - text-transform: none; - color: var(--text-soft); - font-size: 11px; - font-weight: 600; -} -.db-reference-group h3::after { - content: ""; - flex: 1 1 auto; - height: 1px; - background: linear-gradient(90deg, color-mix(in srgb, var(--border) 85%, transparent), transparent); -} -.db-reference-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(252px, 1fr)); - gap: 12px; -} -.db-reference-card { - min-width: 0; - position: relative; - border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); - border-radius: 14px; - background: var(--bg-panel); - padding: 14px; - box-shadow: var(--shadow-xs); - transition: - border-color 180ms cubic-bezier(0.23, 1, 0.32, 1), - box-shadow 180ms cubic-bezier(0.23, 1, 0.32, 1), - transform 180ms cubic-bezier(0.23, 1, 0.32, 1); -} -.db-reference-card:hover { - border-color: color-mix(in srgb, var(--accent) 36%, var(--border)); - box-shadow: 0 12px 28px rgba(28, 27, 26, 0.10), 0 2px 6px rgba(28, 27, 26, 0.05); - transform: translateY(-3px); -} -.db-reference-card > button { - width: 100%; - display: grid; - grid-template-columns: 36px minmax(0, 1fr); - align-items: center; - gap: 12px; - border: 0; - background: transparent; - color: var(--text); - padding: 0; - cursor: pointer; - text-align: left; -} -.db-reference-icon { - width: 36px; - height: 36px; - border: 1px solid color-mix(in srgb, var(--border) 85%, transparent); - border-radius: 10px; - background: var(--bg); - padding: 6px; - transition: - border-color 180ms cubic-bezier(0.23, 1, 0.32, 1), - background 180ms cubic-bezier(0.23, 1, 0.32, 1); -} -.db-reference-card:hover .db-reference-icon { - border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); - background: color-mix(in srgb, var(--accent-tint) 45%, var(--bg)); -} -.db-reference-title { - min-width: 0; - display: flex; - flex-direction: column; - gap: 1px; -} -.db-reference-title span, -.db-reference-title small { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.db-reference-title span { - font-size: 14px; - font-weight: 650; - letter-spacing: -0.01em; + font-weight: 500; color: var(--text-strong); } -.db-reference-title small { - color: var(--text-soft); - font-size: 11.5px; -} -.db-reference-card p { - margin: 12px 0 14px; - min-height: 36px; - color: var(--text-muted); - font-size: 12.5px; - line-height: 1.45; -} -.db-reference-actions { - display: flex; - align-items: center; - gap: 8px; -} -.db-reference-actions button { - flex: 1 1 0; - min-height: 32px; +.df-batch-actions { display: inline-flex; align-items: center; - justify-content: center; - gap: 6px; - border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); - border-radius: 9px; - background: var(--bg-panel); - color: var(--text); + gap: 4px; + flex-shrink: 0; +} +.df-batch-actions button { + background: transparent; + border: 1px solid transparent; + color: var(--text-muted); padding: 5px 10px; - font-size: 12px; - font-weight: 550; - cursor: pointer; - transition: - background 140ms cubic-bezier(0.23, 1, 0.32, 1), - border-color 140ms cubic-bezier(0.23, 1, 0.32, 1), - color 140ms cubic-bezier(0.23, 1, 0.32, 1); -} -.db-reference-actions button svg { - color: var(--text-soft); - transition: color 140ms cubic-bezier(0.23, 1, 0.32, 1); -} -.db-reference-actions button:hover:not(:disabled) { - background: var(--bg-subtle); - border-color: var(--border-strong); -} -.db-reference-actions button:hover:not(:disabled) svg { - color: var(--text); -} -.db-reference-actions button:disabled { - opacity: 0.46; - cursor: default; -} - -@media (max-width: 720px) { - .db-chrome { - grid-template-columns: minmax(0, 1fr); - } - .db-nav, - .db-actions { - justify-content: flex-start; - } - .db-start-hero { - grid-template-columns: minmax(0, 1fr); - align-items: flex-start; - padding: 18px 20px 12px; - } - .db-start h2 { - font-size: 24px; - } - .db-reference-toolbar { - padding: 10px 20px; - } - .db-reference-search { - flex: 1 1 100%; - } - .db-reference-board { - padding: 18px 20px 32px; - } -} -.df-kind-filter-list li + li { - border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); -} -.df-kind-filter-item { - display: grid; - grid-template-columns: 18px 22px 1fr auto; + font-size: 12.5px; + border-radius: var(--radius-sm); + display: inline-flex; align-items: center; - gap: 8px; - padding: 6px 8px; + gap: 6px; cursor: pointer; - color: var(--text); - font-size: 12px; - border-radius: 4px; } -.df-kind-filter-item:hover { +.df-batch-actions button:hover:not(:disabled) { background: var(--bg-subtle); + color: var(--text); } -.df-kind-filter-item input[type='checkbox'] { - margin: 0; - cursor: pointer; -} -.df-kind-filter-glyph { - color: var(--text-faint); - font-size: 11px; - text-align: center; - font-family: var(--mono, ui-monospace, monospace); -} -.df-kind-filter-label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.df-kind-filter-itemcount { - color: var(--text-faint); - font-size: 11px; - font-variant-numeric: tabular-nums; +.df-batch-actions button.danger { color: var(--red); } +.df-batch-actions button.danger:hover:not(:disabled) { + background: var(--red-bg); + color: var(--red); } +.df-batch-clear { color: var(--text-faint); } .df-section { display: flex; flex-direction: column; gap: 0; } -.df-section + .df-section { margin-top: 6px; } +.df-section + .df-section { margin-top: 0; } .df-section-label { font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase; - color: var(--text-faint); + color: var(--text-soft); font-weight: 600; - padding: 12px 20px 6px; -} -.df-section-toggle { - width: auto; - min-width: 0; - display: flex; - align-items: center; - gap: 6px; - border: none; - background: transparent; - text-align: left; - font: inherit; - cursor: pointer; -} -.df-section-toggle:hover { + padding: 8px 20px; background: var(--bg-subtle); - color: var(--text); -} -.df-section-toggle span:first-of-type { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.df-section-row td { - padding: 10px 20px 4px; - border-top: 1px solid var(--border); -} -.df-section-row:first-child td { - border-top: none; + border-bottom: 1px solid var(--border-soft); } .df-section-count { margin-left: 8px; @@ -1789,87 +201,14 @@ font-weight: 500; letter-spacing: 0; } -/* --- file list table --- */ -.df-table { - width: 100%; - border-collapse: collapse; - table-layout: fixed; - --df-check-col: 44px; - --df-icon-col: 48px; - --df-kind-col: 126px; - --df-time-col: 116px; - --df-menu-col: 46px; -} -.df-table thead { border-bottom: 1px solid var(--border); } -.df-th-btn { - all: unset; - cursor: pointer; - display: inline-flex; - align-items: center; - gap: 2px; - width: 100%; - font: inherit; - color: inherit; - letter-spacing: inherit; - text-transform: inherit; -} -.df-th-btn:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - border-radius: 2px; -} - -.df-th-sortable { - cursor: pointer; - user-select: none; - font-size: 10.5px; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-faint); - font-weight: 600; - padding: 12px 20px 6px; - text-align: left; - white-space: nowrap; - transition: color 120ms ease; -} -.df-th-sortable:hover { color: var(--text); } -.df-th-check, .df-cell-check { - width: var(--df-check-col); - box-sizing: border-box; - padding: 10px 0 10px 20px; -} -.df-th-icon, .df-cell-icon { - width: var(--df-icon-col); - box-sizing: border-box; - padding: 10px 0; -} -.df-th-name { - width: auto; - min-width: 0; -} -.df-th-kind { - width: var(--df-kind-col); - box-sizing: border-box; -} -.df-th-time { - width: var(--df-time-col); - box-sizing: border-box; -} -.df-th-menu, .df-cell-menu { - width: var(--df-menu-col); - box-sizing: border-box; - padding: 10px 20px 10px 0; -} -.df-sort-arrow { font-size: 10px; } - .df-file-row { transition: background 120ms ease; } -.df-file-row:hover { background: var(--bg-subtle); } -.df-file-row.active:not(.selected) { background: var(--bg-subtle); } -.df-file-row.active.selected { background: var(--blue-bg); } -.df-file-row.active .df-row-name { color: var(--text-strong); } -.df-file-row.selected { background: var(--blue-bg); } +.df-file-row:hover { background: var(--blue-bg); } +.df-file-row.active:not(.selected) { background: var(--blue-bg); } +.df-file-row.active.selected { background: var(--blue); } +.df-file-row.active:not(.selected) .df-row-name { color: var(--text-strong); } +.df-file-row.selected { background: var(--blue); } .df-cell-check { text-align: center; @@ -1880,10 +219,8 @@ vertical-align: middle; } .df-cell-name { - min-width: 0; padding: 10px 12px 10px 0; vertical-align: middle; - overflow: hidden; /* Force ellipsis truncation in auto-layout tables (#3260). Without `max-width: 0` and `min-width: 0`, a long `f.name` would expand this td and push the kind / mtime / menu cells off-screen — even @@ -1892,19 +229,19 @@ row's children to `width: 100%` when a preview pane is open; this baseline rule covers the no-preview state the issue reports. */ max-width: 0; + min-width: 0; } .df-cell-openable { cursor: pointer; } + .df-row-name-btn { all: unset; cursor: pointer; display: flex; flex-direction: column; gap: 2px; - width: 100%; min-width: 0; - max-width: 100%; } .df-row-name-btn:hover:not(:disabled) { background: transparent; @@ -1915,139 +252,25 @@ outline-offset: 2px; border-radius: 2px; } -.df-cell-kind { - padding: 10px 12px 10px 0; - vertical-align: middle; - overflow: hidden; -} -.df-cell-time { - padding: 10px 12px 10px 0; - vertical-align: middle; - font-size: 11.5px; - color: var(--text-muted); - font-variant-numeric: tabular-nums; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.df-cell-menu { - text-align: center; - vertical-align: middle; -} -.df-kind-label { - display: block; - min-width: 0; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 12px; - color: var(--text-muted); -} - -/* --- pagination --- */ -.df-pagination { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 20px; - font-size: 12px; - color: var(--text-muted); - flex-wrap: wrap; -} -.df-pagination-start { - justify-content: flex-start; -} -.df-pagination-center { - justify-content: center; -} -.df-pagination-end { - justify-content: flex-end; -} -.df-pagination-right { - margin-left: auto; -} -.df-pagination label { - display: inline-flex; - align-items: center; - gap: 4px; - white-space: nowrap; -} -.df-pagination select { - font-size: 12px; - padding: 2px 24px 2px 6px; - border: 1px solid var(--border); - border-radius: 4px; - appearance: none; - -webkit-appearance: none; - background-color: var(--bg); - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%2374716b' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 5 6 8 9 5'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 6px center; - background-size: 12px 12px; - color: var(--text); - cursor: pointer; -} -.df-pagination select::-ms-expand { display: none; } -[data-theme='dark'] .df-pagination select { - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%239a9690' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 5 6 8 9 5'/%3E%3C/svg%3E"); -} -@media (prefers-color-scheme: dark) { - html:not([data-theme]) .df-pagination select { - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%239a9690' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 5 6 8 9 5'/%3E%3C/svg%3E"); - } -} -.df-pagination select:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 1px; -} -.df-page-btn { - background: transparent; - border: 1px solid var(--border); - color: var(--text-muted); - padding: 4px 10px; - font-size: 12px; - border-radius: 4px; - cursor: pointer; - display: inline-flex; - align-items: center; - gap: 4px; - transition: background 120ms ease, color 120ms ease; -} -.df-page-btn:hover:not(:disabled) { - background: var(--bg-subtle); - color: var(--text); -} -.df-page-btn:disabled { - opacity: 0.4; - cursor: default; -} -.df-page-btn:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 1px; -} -.df-page-info { - white-space: nowrap; - color: var(--text-faint); - font-size: 11.5px; -} +.df-row-openable { cursor: pointer; } .df-row { display: grid; grid-template-columns: 28px 36px 1fr auto auto; align-items: center; gap: 12px; - padding: 10px 20px; + margin: 0; + padding: 11px 20px; background: transparent; border: none; + border-bottom: 1px solid var(--border-soft); border-radius: 0; text-align: left; cursor: pointer; - width: 100%; font: inherit; color: inherit; position: relative; - transition: background 120ms ease; + transition: background 120ms cubic-bezier(0.23, 1, 0.32, 1); } .df-row-live-artifact { grid-template-columns: 36px minmax(0, 1fr) auto; @@ -2116,10 +339,14 @@ text-underline-offset: 2px; word-break: break-all; } -.df-row:hover { background: var(--bg-subtle); } -.df-row.active { background: var(--blue-bg); color: var(--text); } -.df-row.active .df-row-name { color: var(--text-strong); } -.df-row.selected { background: var(--blue-bg); } +.df-row:hover { background: var(--blue-bg); } +.df-row.active:not(.selected) { background: var(--blue-bg); color: var(--text); } +.df-row.active:not(.selected) .df-row-name { color: var(--text-strong); } +.df-row.selected { background: var(--blue); } +.df-row.selected .df-row-name { color: #fff; } +.df-row.selected .df-row-sub, +.df-row.selected .df-row-time { color: rgba(255, 255, 255, 0.82); } +.df-row.selected .df-row-check { color: #fff; } .df-row-check { display: inline-flex; @@ -2132,29 +359,17 @@ width: 24px; height: 24px; border-radius: 4px; - transition: background 120ms ease, color 120ms ease; + opacity: 0; + transition: opacity 120ms cubic-bezier(0.23, 1, 0.32, 1), background 120ms ease, color 120ms ease; } +.df-row:hover .df-row-check, +.df-row-check:focus-visible, +.df-row-check[aria-checked="true"], +.df-panel.has-selection .df-row-check { opacity: 1; } .df-row-check:hover { background: var(--border); color: var(--text); } .df-row-check[aria-checked="true"] { color: var(--accent-strong); } +.df-dir-row .df-row-check { cursor: default; } -.df-select-bar { - display: inline-flex; - align-items: center; - gap: 2px; -} -.df-select-all { - background: none; - border: none; - color: var(--text-faint); - cursor: pointer; - font: inherit; - font-size: 11px; - font-weight: 500; - padding: 2px 6px; - border-radius: 4px; - transition: background 120ms ease, color 120ms ease; -} -.df-select-all:hover { background: var(--bg-subtle); color: var(--text); } .df-row-icon { width: 32px; height: 32px; @@ -2174,11 +389,11 @@ .df-row-icon[data-kind="code"] { background: #fff7d8; color: #8c6700; } .df-row-icon[data-kind="text"] { background: var(--bg-subtle); color: var(--text-muted); } .df-row-icon[data-kind="sketch"] { background: var(--purple-bg); color: var(--purple); } +.df-row-icon[data-kind="stylesheet"] { background: var(--blue-bg); color: var(--blue, var(--accent-strong)); } .df-row-name-wrap { display: flex; flex-direction: column; gap: 2px; - width: 100%; min-width: 0; /* Constrain the wrap to the cell's content box so the ellipsis on `.df-row-name` engages even in the auto-layout table state @@ -2186,9 +401,6 @@ max-width: 100%; } .df-row-name { - display: block; - min-width: 0; - max-width: 100%; font-size: 13px; font-weight: 500; color: var(--text); @@ -2221,13 +433,6 @@ align-items: center; gap: 6px; flex-wrap: wrap; - min-width: 0; - max-width: 100%; - line-height: 1.35; -} -.df-row-sub-kind, -.df-row-sub-time { - display: none; } .df-row-time { font-size: 11.5px; @@ -2246,13 +451,12 @@ transition: opacity 120ms ease; } .df-row:hover .df-row-menu { opacity: 1; } -/* Reveal the row action menu on hover/keyboard-focus natively, so file/dir rows - don't need a JS `hover` state that re-renders the whole panel on every row - mouseenter. The active-file / open-menu persistence is still driven inline. */ -.df-file-row:hover .df-row-menu, -.df-file-row:focus-within .df-row-menu { opacity: 1; } .df-row-menu:focus { opacity: 1; } .df-row-menu:hover { background: var(--border); color: var(--text); } +.df-row-menu-placeholder { + pointer-events: none; + visibility: hidden; +} .df-section-more { margin: 6px 20px 10px; width: auto; @@ -2344,8 +548,30 @@ outline-offset: 2px; } +/* Static useful-info footer pinned above the drop zone. */ +.df-useful-info { + margin: auto 20px 4px; + padding: 12px 0 0; + display: flex; + flex-direction: column; + gap: 4px; + border-top: 1px solid var(--border); +} +.df-useful-info-label { + font-size: 10.5px; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 600; + color: var(--accent); +} +.df-useful-info-tip { + font-size: 12px; + line-height: 1.5; + color: var(--text-muted); +} + .df-drop { - margin: 18px 16px 16px; + margin: 12px 16px 16px; padding: 16px; border: 1px dashed var(--border-strong); border-radius: var(--radius); @@ -2378,276 +604,12 @@ max-width: 56ch; } -@media (max-width: 980px) { - .df-panel:not(.no-preview) { - grid-template-columns: minmax(0, 1fr); - grid-template-rows: minmax(0, 1fr) minmax(260px, 38vh); - } - - .df-panel:not(.no-preview) .df-main { - border-right: 0; - } - - .df-panel:not(.no-preview) .df-preview { - border-left: 0; - border-top: 1px solid var(--border); - } -} - -@media (max-width: 860px) { - .df-body { - padding-top: 10px; - } - - .df-th-check, - .df-cell-check { - width: 26px; - padding-left: 14px; - } - - .df-th-kind, - .df-cell-kind { - display: none; - } - - .df-th-menu, - .df-cell-menu { - padding-right: 14px; - } - - .df-cell-name { - padding-right: 10px; - } - - .df-drop { - margin: 14px; - } -} - -@container design-files-main (max-width: 860px) { - .df-body { - padding-top: 10px; - } - - .df-table { - --df-check-col: 40px; - --df-icon-col: 44px; - --df-kind-col: 112px; - --df-time-col: 108px; - --df-menu-col: 42px; - } - - .df-th-check, - .df-cell-check { - padding-left: 14px; - } - - .df-th-menu, - .df-cell-menu { - padding-right: 14px; - } - - .df-cell-name { - padding-right: 10px; - } - - .df-drop { - margin: 14px; - } -} - -@container design-files-main (max-width: 760px) { - .df-table, - .df-table thead, - .df-table tbody { - display: block; - width: 100%; - } - - .df-table thead tr, - .df-file-row { - display: grid; - grid-template-columns: 40px 42px minmax(0, 1fr) 40px; - align-items: center; - width: 100%; - } - - .df-section-row, - .df-section-row td { - display: block; - width: 100%; - box-sizing: border-box; - } - - .df-th-check, - .df-th-icon, - .df-th-name, - .df-th-menu, - .df-cell-check, - .df-cell-icon, - .df-cell-name, - .df-cell-menu { - width: auto; - box-sizing: border-box; - } - - .df-th-check, - .df-cell-check { - padding-left: 12px; - padding-right: 0; - } - - .df-th-icon, - .df-cell-icon { - padding-inline: 0; - } - - .df-th-name, - .df-cell-name { - padding-right: 8px; - } - - .df-th-menu, - .df-cell-menu { - justify-self: end; - padding-right: 12px; - padding-left: 0; - } - - .df-th-kind, - .df-cell-kind, - .df-th-time, - .df-cell-time { - display: none; - } - - .df-row-sub-kind, - .df-row-sub-time { - display: inline; - } - - .df-row-menu { - opacity: 1; - } -} - -@media (max-width: 640px) { - .df-th-time, - .df-cell-time { - display: none; - } - - .df-table { - table-layout: fixed; - } - - .df-th-icon, - .df-cell-icon { - width: 32px; - } - - .df-row-icon { - width: 28px; - height: 28px; - } - - .df-row-name { - font-size: 12.5px; - } -} - -@container design-files-main (max-width: 640px) { - .df-th-time, - .df-cell-time { - display: none; - } - - .df-row-sub-time { - display: inline; - } - - .df-table { - --df-check-col: 38px; - --df-icon-col: 42px; - --df-menu-col: 40px; - } - - .df-th-icon, - .df-cell-icon { - width: var(--df-icon-col); - } - - .df-row-icon { - width: 28px; - height: 28px; - } - - .df-row-name { - font-size: 12.5px; - } - - .df-section-row td { - padding-inline: 14px; - } -} - -@container design-files-main (max-width: 480px) { - .df-table { - --df-check-col: 34px; - --df-icon-col: 38px; - --df-menu-col: 36px; - } - - .df-th-sortable { - padding-inline: 8px; - } - - .df-th-check, - .df-cell-check { - padding-left: 10px; - } - - .df-th-menu, - .df-cell-menu { - padding-right: 10px; - } - - .df-cell-name { - padding-right: 8px; - } - - .df-row-sub { - gap: 5px; - } - - .df-row-plugin-folder { - grid-template-columns: minmax(0, 1fr); - gap: 8px; - } - - .df-plugin-actions { - justify-content: stretch; - } - - .df-plugin-install { - flex: 1 1 132px; - } - - .df-pagination { - padding-inline: 14px; - } - - .df-pagination-right { - margin-left: 0; - } -} - /* Right preview pane in design files */ .df-preview { display: flex; flex-direction: column; min-height: 0; - background: var(--bg-panel); + background: var(--bg); border-left: 1px solid var(--border); position: relative; } @@ -2662,9 +624,10 @@ } .df-preview-thumb { margin: 16px; - border: 1px solid var(--border); + border: 1px solid var(--border-soft); border-radius: var(--radius); - background: var(--bg); + background: var(--bg-panel); + box-shadow: var(--shadow-sm); overflow: hidden; aspect-ratio: 16/10; flex-shrink: 0; @@ -2714,7 +677,48 @@ display: flex; flex-direction: column; gap: 7px; + text-align: center; + align-items: center; } +.df-preview-open-cta { + align-self: stretch; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 36px; + margin-bottom: 4px; + padding: 0 14px; + border: 1px solid var(--border); + border-radius: var(--radius-pill); + background: var(--bg-panel); + color: var(--text-strong); + box-shadow: var(--shadow-xs); + font: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 120ms cubic-bezier(0.23, 1, 0.32, 1), border-color 120ms cubic-bezier(0.23, 1, 0.32, 1); +} +.df-preview-open-cta:hover { + background: var(--bg-panel); + border-color: var(--accent); + color: var(--accent); +} +.df-preview-open-cta:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.df-preview-download { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 2px; + font-size: 12px; + color: var(--text-muted); + text-decoration: none; +} +.df-preview-download:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; } .df-preview-name { font-size: 14px; font-weight: 600; @@ -2818,30 +822,3 @@ .df-row-popover button:hover { background: var(--bg-subtle); } .df-row-popover button.danger { color: var(--red); } .df-row-popover button.danger:hover { background: var(--red-bg); } - -/* Transient confirmation toast (copy path, folder created/deleted, files - moved). Pinned to the top-center of the viewport so it's visible regardless - of scroll position; neutral gray pill. position:fixed keeps it out of the - panel's grid/scroll flow. */ -.df-toast { - position: fixed; - top: 16px; - left: 50%; - transform: translateX(-50%); - z-index: 400; - padding: 7px 14px; - border-radius: 8px; - background: var(--text-muted); - color: var(--bg-panel); - font-size: 12.5px; - font-weight: 500; - line-height: 1.3; - white-space: nowrap; - box-shadow: var(--shadow-md); - pointer-events: none; - animation: df-toast-in 200ms cubic-bezier(0.23, 1, 0.32, 1); -} -@keyframes df-toast-in { - from { opacity: 0; transform: translate(-50%, -10px); } - to { opacity: 1; transform: translate(-50%, 0); } -} diff --git a/apps/web/tests/components/App.project-create-race.test.tsx b/apps/web/tests/components/App.project-create-race.test.tsx index 32578b7927..e6c79cb2c3 100644 --- a/apps/web/tests/components/App.project-create-race.test.tsx +++ b/apps/web/tests/components/App.project-create-race.test.tsx @@ -22,6 +22,7 @@ import { fetchDesignTemplates, fetchPromptTemplates, fetchSkills, + replaceProjectWorkingDir, uploadProjectFiles, } from '../../src/providers/registry'; import { @@ -70,6 +71,21 @@ vi.mock('../../src/components/EntryView', () => ({ > Create project +