mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
feat(web): redesign Design Files panel + pick working directory from Home (#3358)
* 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/<id>` 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>
This commit is contained in:
co-authored by
qiongyu1999
parent
fd8159216a
commit
6408937564
@@ -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 {',
|
||||
|
||||
@@ -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")'],
|
||||
|
||||
@@ -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();');
|
||||
});
|
||||
|
||||
@@ -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<OpenDesignHostPickWorkingDirResult> =>
|
||||
ipcRenderer.invoke('dialog:pick-working-dir')
|
||||
.then(normalizePickWorkingDirResult)
|
||||
.catch((error: unknown) => pickWorkingDirFailure(reasonFromError(error))),
|
||||
};
|
||||
|
||||
const shell = {
|
||||
|
||||
@@ -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<boolean>;
|
||||
/** 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<MintHomeWorkingDirTokenResult> {
|
||||
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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
+51
-1
@@ -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<string | null>(null);
|
||||
const [settingsWelcome, setSettingsWelcome] = useState(false);
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSection>('execution');
|
||||
const [settingsHighlight, setSettingsHighlight] = useState<SettingsHighlight>(null);
|
||||
@@ -1159,6 +1166,7 @@ function AppInner() {
|
||||
autoSendFirstMessage?: boolean;
|
||||
requestId?: string;
|
||||
pendingFiles?: File[];
|
||||
userWorkingDirToken?: string;
|
||||
},
|
||||
): Promise<boolean> => {
|
||||
// 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/<id>` 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/<id>` 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}
|
||||
</AnimatePresence>
|
||||
<MemoryToast onOpenMemory={() => openSettings('memory')} />
|
||||
{workingDirError ? (
|
||||
<Toast
|
||||
message={workingDirError}
|
||||
role="alert"
|
||||
onDismiss={() => 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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -568,6 +568,7 @@ export function EntryShell({
|
||||
...(payload.attachments && payload.attachments.length > 0
|
||||
? { pendingFiles: payload.attachments }
|
||||
: {}),
|
||||
...(payload.workingDirToken ? { userWorkingDirToken: payload.workingDirToken } : {}),
|
||||
autoSendFirstMessage: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<DesignFilesPanel
|
||||
key={projectId}
|
||||
projectId={projectId}
|
||||
resolvedDir={resolvedDir ?? undefined}
|
||||
rootDirName={rootDirName}
|
||||
reloading={reloading}
|
||||
files={visibleFiles}
|
||||
folders={projectFolders}
|
||||
liveArtifacts={liveArtifactEntries}
|
||||
onRefreshFiles={onRefreshFiles}
|
||||
onCurrentDirChange={setUploadDir}
|
||||
onOpenFile={openFile}
|
||||
onOpenLiveArtifact={(tabId) => 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',
|
||||
|
||||
@@ -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<HomeHeroHandle, Props>(function HomeHero(
|
||||
contextItemCount,
|
||||
error,
|
||||
showActivePluginChip = true,
|
||||
workingDir = null,
|
||||
onPickWorkingDir,
|
||||
onClearWorkingDir,
|
||||
onExamplePromptStatusChange,
|
||||
},
|
||||
ref,
|
||||
@@ -1203,6 +1209,31 @@ export const HomeHero = forwardRef<HomeHeroHandle, Props>(function HomeHero(
|
||||
>
|
||||
<Icon name="attach" size={15} />
|
||||
</button>
|
||||
{onPickWorkingDir ? (
|
||||
<div className="home-hero__working-dir-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className={`home-hero__working-dir${workingDir ? ' picked' : ''}`}
|
||||
onClick={onPickWorkingDir}
|
||||
title={workingDir ?? t('workingDirPicker.select')}
|
||||
>
|
||||
<Icon name="folder" size={13} />
|
||||
<span>
|
||||
{workingDir ? workingDir.split(/[/\\]/).filter(Boolean).pop() : t('workingDirPicker.select')}
|
||||
</span>
|
||||
</button>
|
||||
{workingDir ? (
|
||||
<button
|
||||
type="button"
|
||||
className="home-hero__working-dir-clear"
|
||||
onClick={() => onClearWorkingDir?.()}
|
||||
aria-label={t('workingDirPicker.clearAria')}
|
||||
>
|
||||
<Icon name="close" size={10} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<SessionModeToggle
|
||||
mode={sessionMode}
|
||||
onChange={onSessionModeChange}
|
||||
|
||||
@@ -42,7 +42,8 @@ import {
|
||||
localizeSkillPrompt,
|
||||
} from '../i18n/content';
|
||||
import { fetchElevenLabsVoiceOptions } from '../providers/elevenlabs-voices';
|
||||
import { fetchProjectFiles, projectFileUrl } from '../providers/registry';
|
||||
import { fetchProjectFiles, openFolderDialog, projectFileUrl } from '../providers/registry';
|
||||
import { isOpenDesignHostAvailable, pickHostWorkingDir } from '@open-design/host';
|
||||
import type {
|
||||
DesignSystemSummary,
|
||||
Project,
|
||||
@@ -239,6 +240,11 @@ export function HomeView({
|
||||
const [selectedMcpContexts, setSelectedMcpContexts] = useState<SelectedMcpContext[]>([]);
|
||||
const [selectedConnectorContexts, setSelectedConnectorContexts] = useState<SelectedConnectorContext[]>([]);
|
||||
const [stagedFiles, setStagedFiles] = useState<File[]>([]);
|
||||
const [workingDir, setWorkingDir] = useState<string | null>(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<string | null>(null);
|
||||
const [mcpServers, setMcpServers] = useState<McpServerConfig[]>([]);
|
||||
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<string, unknown>) {
|
||||
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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [artifact, setArtifact] = useState<Artifact | null>(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<ProjectFile[]>([]);
|
||||
const projectFilesRef = useRef<ProjectFile[]>([]);
|
||||
const [liveArtifacts, setLiveArtifacts] = useState<LiveArtifactSummary[]>([]);
|
||||
@@ -5157,6 +5162,14 @@ export function ProjectView({
|
||||
<FileWorkspace
|
||||
projectId={project.id}
|
||||
projectKind={projectKindToTracking(project.metadata?.kind) ?? 'prototype'}
|
||||
rootDirName={(() => {
|
||||
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={(
|
||||
<>
|
||||
<WorkingDirPill
|
||||
projectId={project.id}
|
||||
resolvedDir={projectDetail.resolvedDir}
|
||||
onReplaced={({ project: updated }) => {
|
||||
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));
|
||||
}}
|
||||
/>
|
||||
<EntrySettingsMenu
|
||||
config={config}
|
||||
onThemeChange={handleThemeChange}
|
||||
|
||||
@@ -12,9 +12,6 @@ import { useT } from '../i18n';
|
||||
import type { Project } from '../types';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
const RECENT_DIRS_KEY = 'open-design:recent-working-dirs';
|
||||
const RECENT_DIRS_LIMIT = 6;
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
resolvedDir?: string | null;
|
||||
@@ -25,30 +22,8 @@ interface Props {
|
||||
}) => 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<string | null>(null);
|
||||
const [recents, setRecents] = useState<string[]>(() => readRecent());
|
||||
const [fetchedDir, setFetchedDir] = useState<string | null>(null);
|
||||
const wrapRef = useRef<HTMLDivElement | null>(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 (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
@@ -244,29 +210,6 @@ export function WorkingDirPill({ projectId, resolvedDir: propResolvedDir, onRepl
|
||||
<Icon name="folder" size={12} />
|
||||
<span>{t('workingDirPicker.replace')}</span>
|
||||
</button>
|
||||
{showRecents && recents.filter((item) => item !== resolvedDir).length > 0 ? (
|
||||
<>
|
||||
<div className="working-dir-pill-menu-divider" />
|
||||
<div className="working-dir-pill-menu-section">{t('workingDirPicker.recent')}</div>
|
||||
{recents
|
||||
.filter((item) => item !== resolvedDir)
|
||||
.map((dir) => (
|
||||
<button
|
||||
key={dir}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="working-dir-pill-menu-item small"
|
||||
title={dir}
|
||||
onClick={() => void applyDir(dir)}
|
||||
>
|
||||
<Icon name="folder" size={12} />
|
||||
<span className="working-dir-pill-menu-recent">
|
||||
{dir.split('/').filter(Boolean).slice(-2).join('/')}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
{error ? (
|
||||
<>
|
||||
<div className="working-dir-pill-menu-divider" />
|
||||
|
||||
@@ -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': 'لا توجد نتائج',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'بدون نتیجه',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '一致なし',
|
||||
|
||||
@@ -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': '일치 항목 없음',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Нет совпадений',
|
||||
|
||||
@@ -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': 'ไม่เจอรายการที่ตรง',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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-артефакту…',
|
||||
|
||||
@@ -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': '无匹配项',
|
||||
|
||||
@@ -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': '無符合項目',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onCreateProject({
|
||||
name: 'Dir project',
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
metadata: { kind: 'prototype', userWorkingDir: '/Users/me/external' },
|
||||
userWorkingDirToken: 'wd-token',
|
||||
pendingFiles: [new File(['hi'], 'note.txt', { type: 'text/plain' })],
|
||||
})
|
||||
}
|
||||
>
|
||||
Create project with working dir
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -162,6 +178,7 @@ vi.mock('../../src/providers/registry', async () => {
|
||||
fetchDesignTemplates: vi.fn(),
|
||||
fetchPromptTemplates: vi.fn(),
|
||||
fetchSkills: vi.fn(),
|
||||
replaceProjectWorkingDir: vi.fn(),
|
||||
uploadProjectFiles: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -205,6 +222,7 @@ const mockedFetchDesignTemplates = vi.mocked(fetchDesignTemplates);
|
||||
const mockedFetchPromptTemplates = vi.mocked(fetchPromptTemplates);
|
||||
const mockedFetchSkills = vi.mocked(fetchSkills);
|
||||
const mockedUploadProjectFiles = vi.mocked(uploadProjectFiles);
|
||||
const mockedReplaceProjectWorkingDir = vi.mocked(replaceProjectWorkingDir);
|
||||
const mockedCreateProject = vi.mocked(createProject);
|
||||
const mockedDeleteProject = vi.mocked(deleteProject);
|
||||
const mockedGetProject = vi.mocked(getProject);
|
||||
@@ -809,4 +827,66 @@ describe('App project creation routing', () => {
|
||||
});
|
||||
expect(screen.queryByTestId('entry-project-project-existing')).toBeNull();
|
||||
});
|
||||
|
||||
it('switches to the picked working dir before uploading staged Home attachments', async () => {
|
||||
// Regression for the "picked working dir + staged attachment" case:
|
||||
// replaceProjectWorkingDir flips metadata.baseDir to the external folder,
|
||||
// so it must run BEFORE uploadProjectFiles — otherwise the staged files
|
||||
// land in the temporary managed .od/projects/<id> root and vanish once the
|
||||
// working dir flips. Asserting the call order locks the ordering in.
|
||||
mockedListProjects.mockResolvedValue([]);
|
||||
mockedReplaceProjectWorkingDir.mockResolvedValue(undefined as never);
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: 'Create project with working dir' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedReplaceProjectWorkingDir).toHaveBeenCalledTimes(1);
|
||||
expect(mockedUploadProjectFiles).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockedReplaceProjectWorkingDir).toHaveBeenCalledWith(
|
||||
'project-new',
|
||||
'/Users/me/external',
|
||||
'wd-token',
|
||||
);
|
||||
// Both target the same project id, and the working-dir handoff is ordered
|
||||
// strictly before the upload so the files land in the final tree.
|
||||
expect(mockedUploadProjectFiles.mock.calls[0]?.[0]).toBe('project-new');
|
||||
const replaceOrder = mockedReplaceProjectWorkingDir.mock.invocationCallOrder[0]!;
|
||||
const uploadOrder = mockedUploadProjectFiles.mock.invocationCallOrder[0]!;
|
||||
expect(replaceOrder).toBeLessThan(uploadOrder);
|
||||
});
|
||||
|
||||
it('short-circuits the upload + auto-send when the working-dir handoff fails', async () => {
|
||||
// Regression for the swallowed-failure case: the desktop working-dir token
|
||||
// has a ~60s TTL, so a slow user (or any rejected POST) makes
|
||||
// replaceProjectWorkingDir throw AFTER the project already exists. The old
|
||||
// code only logged a warning and then uploaded the staged attachments into
|
||||
// the managed root while the user believed their chosen folder was applied.
|
||||
// The fix surfaces a create-time error toast AND aborts the rest of the
|
||||
// submit path so the first run cannot proceed on a tree the user did not
|
||||
// choose.
|
||||
mockedListProjects.mockResolvedValue([]);
|
||||
mockedReplaceProjectWorkingDir.mockRejectedValue(
|
||||
new Error('working-dir token expired'),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: 'Create project with working dir' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Couldn't apply the chosen folder/i)).toBeTruthy();
|
||||
});
|
||||
expect(mockedReplaceProjectWorkingDir).toHaveBeenCalledTimes(1);
|
||||
// The handoff failed, so the staged attachments must NOT be uploaded into
|
||||
// the managed `.od/projects/<id>` root the user did not pick.
|
||||
expect(mockedUploadProjectFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,14 +90,14 @@ describe('DesignFilesPanel long filename truncation (#3260)', () => {
|
||||
expect(nameSpan?.getAttribute('title')).toBe(LONG_NAME);
|
||||
});
|
||||
|
||||
it('keeps the truncate-friendly DOM structure (.df-cell-name > .df-row-name-btn > .df-row-name-wrap > .df-row-name)', () => {
|
||||
it('keeps the truncate-friendly DOM structure (.df-row-name-wrap > .df-row-name-btn > .df-row-name-wrap > .df-row-name)', () => {
|
||||
const { container } = renderPanel([file({ name: LONG_NAME })]);
|
||||
// The CSS fix relies on this nesting: `td.df-cell-name` constrains
|
||||
// its width, the wrap is min-width:0 / max-width:100%, and
|
||||
// `.df-row-name` carries `text-overflow: ellipsis`. If the JSX
|
||||
// shape ever changes the CSS regression risk returns silently —
|
||||
// this asserts the chain stays intact.
|
||||
const cell = container.querySelector('td.df-cell-name');
|
||||
// The CSS fix relies on this nesting: the outer `.df-row-name-wrap`
|
||||
// cell constrains its width, the inner wrap is min-width:0 /
|
||||
// max-width:100%, and `.df-row-name` carries `text-overflow: ellipsis`.
|
||||
// If the JSX shape ever changes the CSS regression risk returns
|
||||
// silently — this asserts the chain stays intact.
|
||||
const cell = container.querySelector('div.df-row-name-wrap');
|
||||
expect(cell).toBeTruthy();
|
||||
const btn = cell!.querySelector('button.df-row-name-btn');
|
||||
expect(btn).toBeTruthy();
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { DesignFilesPanel } from '../../src/components/DesignFilesPanel';
|
||||
import type { ProjectFile, ProjectFileKind } from '../../src/types';
|
||||
@@ -60,404 +59,143 @@ function renderPanel(
|
||||
) {
|
||||
const onOpenFile = vi.fn();
|
||||
const onDeleteFiles = vi.fn();
|
||||
const onUploadFiles = vi.fn();
|
||||
const onRenameFile = vi.fn();
|
||||
const onClearUploadError = vi.fn();
|
||||
const result = render(
|
||||
<DesignFilesPanel
|
||||
projectId="test-project"
|
||||
files={files}
|
||||
folders={[]}
|
||||
liveArtifacts={[]}
|
||||
onRefreshFiles={vi.fn()}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenLiveArtifact={vi.fn()}
|
||||
onRenameFile={onRenameFile}
|
||||
onRenameFile={vi.fn()}
|
||||
onDeleteFile={vi.fn()}
|
||||
onDeleteFiles={onDeleteFiles}
|
||||
onUpload={vi.fn()}
|
||||
onUploadFiles={onUploadFiles}
|
||||
onUploadFiles={vi.fn()}
|
||||
onPaste={vi.fn()}
|
||||
onNewSketch={vi.fn()}
|
||||
onClearUploadError={onClearUploadError}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
onDeleteFiles,
|
||||
onOpenFile,
|
||||
onRenameFile,
|
||||
onUploadFiles,
|
||||
onClearUploadError,
|
||||
};
|
||||
return { ...result, onDeleteFiles, onOpenFile, onClearUploadError };
|
||||
}
|
||||
|
||||
function getPageInfo(container: HTMLElement): string {
|
||||
const el = container.querySelector('.df-page-info');
|
||||
return el?.textContent?.trim() ?? '';
|
||||
function sectionLabels(): string[] {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>('.df-section-label')).map(
|
||||
(el) => el.textContent ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/** page-btn order: bottom-Prev=0, bottom-Next=1 */
|
||||
function getPageBtns(container: HTMLElement) {
|
||||
return Array.from(container.querySelectorAll<HTMLButtonElement>('.df-page-btn'));
|
||||
}
|
||||
|
||||
function getSelects(container: HTMLElement) {
|
||||
return Array.from(container.querySelectorAll<HTMLSelectElement>('select'));
|
||||
}
|
||||
|
||||
describe('DesignFilesPanel grouping', () => {
|
||||
beforeEach(() => {
|
||||
lsStore.clear();
|
||||
});
|
||||
|
||||
describe('DesignFilesPanel sections', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not show grouping controls when only live artifacts are available', () => {
|
||||
render(
|
||||
<DesignFilesPanel
|
||||
projectId="project-1"
|
||||
files={[]}
|
||||
liveArtifacts={[
|
||||
{
|
||||
kind: 'live-artifact',
|
||||
artifactId: 'artifact-1',
|
||||
tabId: 'live:artifact-1',
|
||||
projectId: 'project-1',
|
||||
title: 'Live Preview',
|
||||
slug: 'live-preview',
|
||||
status: 'active',
|
||||
refreshStatus: 'idle',
|
||||
pinned: false,
|
||||
preview: { type: 'html', entry: 'index.html' },
|
||||
hasDocument: true,
|
||||
updatedAt: '2026-05-09T12:00:00.000Z',
|
||||
},
|
||||
]}
|
||||
onRefreshFiles={vi.fn()}
|
||||
onOpenFile={vi.fn()}
|
||||
onOpenLiveArtifact={vi.fn()}
|
||||
onRenameFile={vi.fn()}
|
||||
onDeleteFile={vi.fn()}
|
||||
onDeleteFiles={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onUploadFiles={vi.fn()}
|
||||
onPaste={vi.fn()}
|
||||
onNewSketch={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
it('does not show grouping, sort, filter, or pagination chrome', () => {
|
||||
renderPanel(generateFiles(60));
|
||||
|
||||
expect(screen.queryByRole('group', { name: 'Group by' })).toBeNull();
|
||||
expect(screen.getByTestId('design-file-row-live:artifact-1')).toBeTruthy();
|
||||
expect(document.querySelector('.df-table')).toBeNull();
|
||||
expect(document.querySelector('.df-th-sortable')).toBeNull();
|
||||
expect(document.querySelector('.df-kind-filter')).toBeNull();
|
||||
expect(document.querySelector('.df-pagination')).toBeNull();
|
||||
expect(document.querySelector('.df-page-btn')).toBeNull();
|
||||
});
|
||||
|
||||
it('groups files by kind when kind grouping is selected', () => {
|
||||
it('renders a single-line toolbar with file actions and no up/refresh buttons', () => {
|
||||
renderPanel([file({ name: 'page.html', kind: 'html' })]);
|
||||
|
||||
expect(document.querySelector('.df-topbar')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: 'Up' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull();
|
||||
expect(document.querySelector('.df-up-btn')).toBeNull();
|
||||
expect(document.querySelector('.df-refresh-control')).toBeNull();
|
||||
expect(screen.getByTestId('design-files-upload-trigger')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('groups files into semantic sections by category', () => {
|
||||
renderPanel([
|
||||
file({ name: 'page.html', kind: 'html', mime: 'text/html' }),
|
||||
file({ name: 'chart.png', kind: 'image', mime: 'image/png' }),
|
||||
]);
|
||||
|
||||
const sectionLabels = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('.df-section-label'),
|
||||
).map((el) => el.textContent ?? '');
|
||||
expect(sectionLabels.some((text) => text.includes('HTML page'))).toBe(true);
|
||||
expect(sectionLabels.some((text) => text.includes('Image'))).toBe(true);
|
||||
const labels = sectionLabels();
|
||||
expect(labels.some((text) => text.includes('Pages'))).toBe(true);
|
||||
expect(labels.some((text) => text.includes('Images'))).toBe(true);
|
||||
expect(screen.getByTestId('design-file-row-page.html')).toBeTruthy();
|
||||
expect(screen.getByTestId('design-file-row-chart.png')).toBeTruthy();
|
||||
expect(screen.queryByText('Today')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps kind grouping selected by default', () => {
|
||||
it('splits stylesheets into their own section with a Stylesheet subtitle', () => {
|
||||
renderPanel([
|
||||
file({ name: 'page.html', kind: 'html', mime: 'text/html' }),
|
||||
file({ name: 'chart.png', kind: 'image', mime: 'image/png' }),
|
||||
file({ name: 'styles.css', kind: 'code', mime: 'text/css' }),
|
||||
file({ name: 'app.ts', kind: 'code', mime: 'text/typescript' }),
|
||||
]);
|
||||
|
||||
const groupControls = screen.getByRole('group', { name: 'Group by' });
|
||||
const kindGroupButton = within(groupControls).getByRole('button', { name: 'Kind' });
|
||||
expect(kindGroupButton.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(screen.getByText('Name')).toBeTruthy();
|
||||
expect(document.querySelector('.df-th-kind')?.textContent).toContain('Kind');
|
||||
expect(screen.queryByText('Today')).toBeNull();
|
||||
const labels = sectionLabels();
|
||||
expect(labels.some((text) => text.includes('Stylesheets'))).toBe(true);
|
||||
expect(labels.some((text) => text.includes('Scripts'))).toBe(true);
|
||||
|
||||
const cssRow = screen.getByTestId('design-file-row-styles.css');
|
||||
expect(cssRow.querySelector('.df-row-sub')?.textContent).toBe('Stylesheet');
|
||||
const tsRow = screen.getByTestId('design-file-row-app.ts');
|
||||
expect(tsRow.querySelector('.df-row-sub')?.textContent).toBe('Script');
|
||||
});
|
||||
|
||||
it('can group files by modified date and collapse a date group', () => {
|
||||
const now = new Date(2026, 4, 9, 12).getTime();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
it('shows the type as the row subtitle instead of file size', () => {
|
||||
renderPanel([file({ name: 'chart.png', kind: 'image', size: 4096 })]);
|
||||
|
||||
renderPanel([
|
||||
file({ name: 'today.html', mtime: new Date(2026, 4, 9, 11).getTime() }),
|
||||
file({ name: 'yesterday.html', mtime: new Date(2026, 4, 8, 12).getTime() }),
|
||||
]);
|
||||
|
||||
expect(screen.queryByText('Today')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
|
||||
|
||||
expect(screen.getByText('Today')).toBeTruthy();
|
||||
expect(screen.getByText('Yesterday')).toBeTruthy();
|
||||
expect(screen.getByTestId('design-file-row-today.html')).toBeTruthy();
|
||||
expect(screen.getByTestId('design-file-row-yesterday.html')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Collapse Today/i }));
|
||||
|
||||
expect(screen.queryByTestId('design-file-row-today.html')).toBeNull();
|
||||
expect(screen.getByTestId('design-file-row-yesterday.html')).toBeTruthy();
|
||||
const row = screen.getByTestId('design-file-row-chart.png');
|
||||
expect(row.querySelector('.df-row-sub')?.textContent).toBe('Image');
|
||||
expect(row.textContent).not.toContain('KB');
|
||||
});
|
||||
|
||||
it('keeps files from seven calendar days ago in the previous 7 days group', () => {
|
||||
const now = new Date(2026, 4, 9, 12).getTime();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
it('renders a static useful-info footer', () => {
|
||||
renderPanel([file({ name: 'page.html', kind: 'html' })]);
|
||||
|
||||
renderPanel([file({ name: 'week-old.html', mtime: new Date(2026, 4, 2, 12).getTime() })]);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
|
||||
|
||||
expect(screen.getByText('Previous 7 days')).toBeTruthy();
|
||||
expect(screen.queryByText('Previous 30 days')).toBeNull();
|
||||
expect(screen.getByTestId('design-file-row-week-old.html')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps files at the seven calendar day boundary in the previous 7 days group', () => {
|
||||
const now = new Date(2026, 4, 9, 12).getTime();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
renderPanel([
|
||||
file({ name: 'week-boundary.html', mtime: new Date(2026, 4, 2, 0, 0, 0, 0).getTime() }),
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
|
||||
|
||||
expect(screen.getByText('Previous 7 days')).toBeTruthy();
|
||||
expect(screen.queryByText('Previous 30 days')).toBeNull();
|
||||
expect(screen.getByTestId('design-file-row-week-boundary.html')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps files from thirty calendar days ago in the previous 30 days group', () => {
|
||||
const now = new Date(2026, 4, 9, 12).getTime();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
renderPanel([
|
||||
file({ name: 'month-old.html', mtime: new Date(2026, 3, 9, 12).getTime() }),
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
|
||||
|
||||
expect(screen.getByText('Previous 30 days')).toBeTruthy();
|
||||
expect(screen.queryByText('Older')).toBeNull();
|
||||
expect(screen.getByTestId('design-file-row-month-old.html')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps files at the thirty calendar day boundary in the previous 30 days group', () => {
|
||||
const now = new Date(2026, 4, 9, 12).getTime();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
renderPanel([
|
||||
file({
|
||||
name: 'month-boundary.html',
|
||||
mtime: new Date(2026, 3, 9, 0, 0, 0, 0).getTime(),
|
||||
}),
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
|
||||
|
||||
expect(screen.getByText('Previous 30 days')).toBeTruthy();
|
||||
expect(screen.queryByText('Older')).toBeNull();
|
||||
expect(screen.getByTestId('design-file-row-month-boundary.html')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('groups files older than thirty calendar days into older', () => {
|
||||
const now = new Date(2026, 4, 9, 12).getTime();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
renderPanel([file({ name: 'archive.html', mtime: new Date(2026, 3, 8, 12).getTime() })]);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
|
||||
|
||||
expect(screen.getByText('Older')).toBeTruthy();
|
||||
expect(screen.queryByText('Previous 30 days')).toBeNull();
|
||||
expect(screen.getByTestId('design-file-row-archive.html')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('groups only the current page so large file lists stay paginated', () => {
|
||||
const now = new Date(2026, 4, 9, 12).getTime();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
renderPanel(
|
||||
Array.from({ length: 31 }, (_, i) =>
|
||||
file({ name: `today-${String(i + 1).padStart(2, '0')}.html`, mtime: now - i }),
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
|
||||
|
||||
expect(screen.getByTestId('design-file-row-today-01.html')).toBeTruthy();
|
||||
expect(screen.queryByTestId('design-file-row-today-31.html')).toBeNull();
|
||||
expect(getPageInfo(document.body)).toContain('1–30 of 31');
|
||||
});
|
||||
|
||||
it('updates modified date groups when the local day changes', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 4, 9, 23, 59, 50));
|
||||
|
||||
renderPanel([file({ name: 'late-edit.html', mtime: new Date(2026, 4, 9, 23, 59).getTime() })]);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Modified' }));
|
||||
|
||||
expect(screen.getByText('Today')).toBeTruthy();
|
||||
expect(screen.queryByText('Yesterday')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10_001);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Yesterday')).toBeTruthy();
|
||||
expect(screen.queryByText('Today')).toBeNull();
|
||||
expect(screen.getByTestId('design-file-row-late-edit.html')).toBeTruthy();
|
||||
expect(document.querySelector('.df-useful-info-label')?.textContent).toBe('Useful info');
|
||||
expect(document.querySelector('.df-useful-info-tip')?.textContent).toContain('Double-click');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DesignFilesPanel large-list regression', () => {
|
||||
beforeEach(() => {
|
||||
lsStore.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders only the default page size (30) rows with 500 files', () => {
|
||||
const files = generateFiles(500);
|
||||
const { container } = renderPanel(files);
|
||||
expect(container.querySelectorAll('.df-file-row').length).toBe(30);
|
||||
});
|
||||
|
||||
it('shows all 500 rows when page size is set to All', () => {
|
||||
const files = generateFiles(500);
|
||||
const { container } = renderPanel(files);
|
||||
|
||||
const selects = getSelects(container);
|
||||
fireEvent.change(selects[0]!, { target: { value: 'all' } });
|
||||
describe('DesignFilesPanel large list', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('renders all rows at once (no pagination)', () => {
|
||||
const { container } = renderPanel(generateFiles(500));
|
||||
expect(container.querySelectorAll('.df-file-row').length).toBe(500);
|
||||
});
|
||||
|
||||
it('shows 60 rows when page size is changed to 60', () => {
|
||||
it('renders 500 files within a reasonable time', () => {
|
||||
const files = generateFiles(500);
|
||||
const { container } = renderPanel(files);
|
||||
|
||||
const selects = getSelects(container);
|
||||
fireEvent.change(selects[0]!, { target: { value: '60' } });
|
||||
|
||||
expect(container.querySelectorAll('.df-file-row').length).toBe(60);
|
||||
const start = performance.now();
|
||||
renderPanel(files);
|
||||
const elapsed = performance.now() - start;
|
||||
expect(elapsed).toBeLessThan(2000);
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates pages with Next button and updates row content', () => {
|
||||
const files = generateFiles(500);
|
||||
const { container } = renderPanel(files);
|
||||
describe('DesignFilesPanel selection', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
expect(container.querySelectorAll('.df-file-row').length).toBe(30);
|
||||
expect(container.querySelector('.df-file-row')!.textContent).toContain('file-1');
|
||||
it('shows the batch bar and passes every selected file to batch delete', () => {
|
||||
const files = generateFiles(3);
|
||||
const { container, onDeleteFiles } = renderPanel(files);
|
||||
const rows = Array.from(container.querySelectorAll('.df-file-row'));
|
||||
|
||||
const btns = getPageBtns(container);
|
||||
fireEvent.click(btns[1]!);
|
||||
const firstName = rows[0]!.getAttribute('data-testid')!.replace(/^design-file-row-/, '');
|
||||
const secondName = rows[1]!.getAttribute('data-testid')!.replace(/^design-file-row-/, '');
|
||||
fireEvent.click(rows[0]!.querySelector('.df-row-check')!);
|
||||
fireEvent.click(rows[1]!.querySelector('.df-row-check')!);
|
||||
|
||||
expect(container.querySelectorAll('.df-file-row').length).toBe(30);
|
||||
expect(container.querySelector('.df-file-row')!.textContent).toContain('file-31');
|
||||
});
|
||||
expect(container.querySelector('[data-testid="design-files-batch-bar"]')).toBeTruthy();
|
||||
|
||||
it('shows disabled Previous on first page and Next on last page', () => {
|
||||
const files = generateFiles(45);
|
||||
const { container } = renderPanel(files);
|
||||
|
||||
const btns = getPageBtns(container);
|
||||
expect(btns[0]!.disabled).toBe(true);
|
||||
expect(btns[1]!.disabled).toBe(false);
|
||||
|
||||
fireEvent.click(btns[1]!);
|
||||
const btns2 = getPageBtns(container);
|
||||
expect(btns2[0]!.disabled).toBe(false);
|
||||
|
||||
fireEvent.click(getPageBtns(container)[1]!);
|
||||
fireEvent.click(getPageBtns(container)[1]!);
|
||||
expect(getPageBtns(container)[1]!.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('jumps to a specific page via page dropdown at bottom', () => {
|
||||
const files = generateFiles(200);
|
||||
const { container } = renderPanel(files);
|
||||
|
||||
const selects = getSelects(container);
|
||||
fireEvent.change(selects[1]!, { target: { value: '3' } });
|
||||
|
||||
expect(container.querySelector('.df-file-row')!.textContent).toContain('file-91');
|
||||
});
|
||||
|
||||
it('updates page info text when navigating', () => {
|
||||
const files = generateFiles(500);
|
||||
const { container } = renderPanel(files);
|
||||
|
||||
expect(getPageInfo(container)).toContain('1–30 of 500');
|
||||
|
||||
const btns = getPageBtns(container);
|
||||
fireEvent.click(btns[1]!);
|
||||
|
||||
expect(getPageInfo(container)).toContain('31–60 of 500');
|
||||
});
|
||||
|
||||
it('keeps the bulk toolbar focused on the all-files action instead of duplicating page select', () => {
|
||||
const { container } = renderPanel(generateFiles(20));
|
||||
|
||||
const toolbar = container.querySelector('.df-select-bar');
|
||||
expect(toolbar?.textContent).toContain('Select everything');
|
||||
expect(toolbar?.textContent).not.toContain('Select all on page');
|
||||
});
|
||||
|
||||
it('hides redundant pagination controls for a single small page', () => {
|
||||
const { container } = renderPanel(generateFiles(3));
|
||||
|
||||
expect(container.querySelector('.df-pagination')).toBeNull();
|
||||
expect(container.querySelector('.df-page-btn')).toBeNull();
|
||||
expect(container.querySelector('.df-select-bar')).toBeNull();
|
||||
});
|
||||
|
||||
it('uses non-control table cells as file row click targets', () => {
|
||||
const files = generateFiles(1);
|
||||
const { container, onOpenFile } = renderPanel(files);
|
||||
const row = container.querySelector('.df-file-row')!;
|
||||
|
||||
fireEvent.click(row.querySelector('.df-cell-icon')!);
|
||||
expect(container.querySelector('[data-testid="design-file-preview"]')?.textContent).toContain(
|
||||
'file-1.html',
|
||||
);
|
||||
|
||||
fireEvent.click(row.querySelector('.df-cell-kind')!);
|
||||
expect(container.querySelector('[data-testid="design-file-preview"]')?.textContent).toContain(
|
||||
'file-1.html',
|
||||
);
|
||||
|
||||
fireEvent.click(row.querySelector('.df-cell-name')!);
|
||||
expect(container.querySelector('[data-testid="design-file-preview"]')?.textContent).toContain(
|
||||
'file-1.html',
|
||||
);
|
||||
|
||||
fireEvent.doubleClick(row.querySelector('.df-cell-name')!);
|
||||
expect(onOpenFile).toHaveBeenCalledWith('file-1.html');
|
||||
onOpenFile.mockClear();
|
||||
|
||||
fireEvent.doubleClick(row.querySelector('.df-cell-time')!);
|
||||
expect(onOpenFile).toHaveBeenCalledWith('file-1.html');
|
||||
fireEvent.click(container.querySelector('[data-testid="design-files-batch-delete"]')!);
|
||||
expect(onDeleteFiles).toHaveBeenCalledTimes(1);
|
||||
expect(onDeleteFiles).toHaveBeenCalledWith([firstName, secondName]);
|
||||
});
|
||||
|
||||
it('does not preview or open files from row controls', () => {
|
||||
@@ -474,6 +212,36 @@ describe('DesignFilesPanel large-list regression', () => {
|
||||
expect(onOpenFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses non-control row targets to preview and open', () => {
|
||||
const files = generateFiles(1);
|
||||
const { container, onOpenFile } = renderPanel(files);
|
||||
const row = container.querySelector('.df-file-row')!;
|
||||
|
||||
fireEvent.click(row.querySelector('.df-row-icon')!);
|
||||
expect(container.querySelector('[data-testid="design-file-preview"]')?.textContent).toContain(
|
||||
'file-1.html',
|
||||
);
|
||||
|
||||
fireEvent.doubleClick(row.querySelector('.df-row-name-btn')!);
|
||||
expect(onOpenFile).toHaveBeenCalledWith('file-1.html');
|
||||
onOpenFile.mockClear();
|
||||
|
||||
fireEvent.doubleClick(row.querySelector('.df-row-time')!);
|
||||
expect(onOpenFile).toHaveBeenCalledWith('file-1.html');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DesignFilesPanel preview', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('shows the file extension in the preview stats', () => {
|
||||
const { container } = renderPanel([file({ name: 'chart.png', kind: 'image', size: 4096 })]);
|
||||
fireEvent.click(container.querySelector('.df-file-row .df-row-icon')!);
|
||||
|
||||
const stats = container.querySelector('.df-preview-stats')?.textContent ?? '';
|
||||
expect(stats).toContain('PNG');
|
||||
});
|
||||
|
||||
it('renders sketch files with the static sketch preview instead of a broken image', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
||||
version: 1,
|
||||
@@ -510,54 +278,6 @@ describe('DesignFilesPanel large-list regression', () => {
|
||||
expect(container.querySelector('.df-preview-thumb img')).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/projects/test-project/raw/board.sketch.json', { cache: 'no-store' });
|
||||
});
|
||||
|
||||
it('passes every selected file to batch delete', () => {
|
||||
const files = generateFiles(3);
|
||||
const { container, onDeleteFiles } = renderPanel(files);
|
||||
const rows = Array.from(container.querySelectorAll('.df-file-row'));
|
||||
|
||||
const firstName = rows[0]!.getAttribute('data-testid')!.replace(/^design-file-row-/, '');
|
||||
const secondName = rows[1]!.getAttribute('data-testid')!.replace(/^design-file-row-/, '');
|
||||
fireEvent.click(rows[0]!.querySelector('.df-row-check')!);
|
||||
fireEvent.click(rows[1]!.querySelector('.df-row-check')!);
|
||||
fireEvent.click(container.querySelector('[data-testid="design-files-batch-delete"]')!);
|
||||
|
||||
expect(onDeleteFiles).toHaveBeenCalledTimes(1);
|
||||
expect(onDeleteFiles).toHaveBeenCalledWith([firstName, secondName]);
|
||||
});
|
||||
|
||||
it('drops hidden selections before batch delete when a kind filter narrows the file list', () => {
|
||||
const files = [
|
||||
file({ name: 'landing.html', kind: 'html', mime: 'text/html' }),
|
||||
file({ name: 'hero.png', kind: 'image', mime: 'image/png' }),
|
||||
];
|
||||
const { onDeleteFiles } = renderPanel(files);
|
||||
|
||||
fireEvent.click(screen.getByTestId('design-file-row-landing.html').querySelector('.df-row-check')!);
|
||||
fireEvent.click(screen.getByTestId('design-file-row-hero.png').querySelector('.df-row-check')!);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Filter by kind' }));
|
||||
const filterDialog = screen.getByRole('dialog', { name: 'Filter by kind' });
|
||||
const imageFilterLabel = within(filterDialog).getByText('Image').closest('label');
|
||||
if (!imageFilterLabel) throw new Error('Missing image filter label');
|
||||
fireEvent.click(imageFilterLabel.querySelector('input')!);
|
||||
|
||||
expect(screen.queryByTestId('design-file-row-landing.html')).toBeNull();
|
||||
expect(screen.getByTestId('design-file-row-hero.png')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByTestId('design-files-batch-delete'));
|
||||
|
||||
expect(onDeleteFiles).toHaveBeenCalledTimes(1);
|
||||
expect(onDeleteFiles).toHaveBeenCalledWith(['hero.png']);
|
||||
});
|
||||
|
||||
it('renders 500 files within a reasonable time', () => {
|
||||
const files = generateFiles(500);
|
||||
const start = performance.now();
|
||||
renderPanel(files);
|
||||
const elapsed = performance.now() - start;
|
||||
expect(elapsed).toBeLessThan(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DesignFilesPanel directory navigation', () => {
|
||||
@@ -577,6 +297,15 @@ describe('DesignFilesPanel directory navigation', () => {
|
||||
expect(dirRows[0]!.textContent).toContain('2');
|
||||
});
|
||||
|
||||
it('pins folders into a Folders section', () => {
|
||||
renderPanel([
|
||||
file({ name: 'assets/logo.png', kind: 'image' }),
|
||||
file({ name: 'top.html', kind: 'html' }),
|
||||
]);
|
||||
|
||||
expect(sectionLabels().some((text) => text.includes('Folders'))).toBe(true);
|
||||
});
|
||||
|
||||
it('clicking a folder row navigates into it and shows only basenames and nested dirs', () => {
|
||||
renderPanel([
|
||||
file({ name: 'assets/logo.png', kind: 'image' }),
|
||||
@@ -597,6 +326,26 @@ describe('DesignFilesPanel directory navigation', () => {
|
||||
expect(dirRows[0]!.textContent).toContain('icons');
|
||||
});
|
||||
|
||||
it('always renders the root breadcrumb on the default-root view', () => {
|
||||
// Regression: managed-storage projects have currentDir==='' and no
|
||||
// rootDirName, which previously collapsed the whole breadcrumb nav to null
|
||||
// and left the toolbar blank on the left for the most common path. The root
|
||||
// crumb must always render, falling back to the t('designFiles.crumbs')
|
||||
// label when no rootDirName exists.
|
||||
renderPanel([file({ name: 'top.html', kind: 'html' })]);
|
||||
|
||||
expect(document.querySelector('.df-breadcrumbs')).toBeTruthy();
|
||||
expect(document.querySelector('.df-breadcrumb-current')?.textContent).toBe('project');
|
||||
});
|
||||
|
||||
it('shows rootDirName as the root breadcrumb when one is provided', () => {
|
||||
renderPanel([file({ name: 'top.html', kind: 'html' })], {
|
||||
rootDirName: 'my-folder',
|
||||
});
|
||||
|
||||
expect(document.querySelector('.df-breadcrumb-current')?.textContent).toBe('my-folder');
|
||||
});
|
||||
|
||||
it('clicking the root breadcrumb navigates back to root', () => {
|
||||
renderPanel([
|
||||
file({ name: 'assets/logo.png', kind: 'image' }),
|
||||
@@ -608,12 +357,24 @@ describe('DesignFilesPanel directory navigation', () => {
|
||||
|
||||
fireEvent.click(document.querySelector('.df-breadcrumb-btn')!);
|
||||
|
||||
expect(document.querySelector('.df-breadcrumbs')).toBeNull();
|
||||
expect(document.querySelector('.df-breadcrumb-current')?.textContent).not.toBe('assets');
|
||||
expect(screen.getByTestId('design-file-row-top.html')).toBeTruthy();
|
||||
expect(document.querySelectorAll('.df-dir-row').length).toBe(1);
|
||||
});
|
||||
|
||||
it('clears selection and resets page when navigating into or out of a directory', () => {
|
||||
it('navigates up one level via the parent breadcrumb', () => {
|
||||
renderPanel([file({ name: 'assets/icons/star.svg', kind: 'image' })]);
|
||||
|
||||
fireEvent.click(document.querySelector('.df-dir-row .df-row-name-btn')!);
|
||||
fireEvent.click(document.querySelector('.df-dir-row .df-row-name-btn')!);
|
||||
expect(document.querySelector('.df-breadcrumb-current')?.textContent).toBe('icons');
|
||||
|
||||
const crumbs = Array.from(document.querySelectorAll('.df-breadcrumb-btn'));
|
||||
fireEvent.click(crumbs[crumbs.length - 1]!);
|
||||
expect(document.querySelector('.df-breadcrumb-current')?.textContent).toBe('assets');
|
||||
});
|
||||
|
||||
it('clears selection when navigating into or out of a directory', () => {
|
||||
renderPanel([
|
||||
file({ name: 'assets/logo.png', kind: 'image' }),
|
||||
file({ name: 'top.html', kind: 'html' }),
|
||||
@@ -663,183 +424,7 @@ describe('DesignFilesPanel directory navigation', () => {
|
||||
|
||||
rerender(makePanel([file({ name: 'top.html', kind: 'html' })]));
|
||||
|
||||
expect(document.querySelector('.df-breadcrumbs')).toBeNull();
|
||||
expect(document.querySelector('.df-breadcrumb-current')?.textContent).not.toBe('assets');
|
||||
expect(screen.getByTestId('design-file-row-top.html')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns to root after batch deleting the last files in the current subdirectory', async () => {
|
||||
const deleteSpy = vi.fn();
|
||||
|
||||
function Harness() {
|
||||
const [files, setFiles] = useState<ProjectFile[]>([
|
||||
file({ name: 'assets/logo.png', kind: 'image' }),
|
||||
file({ name: 'assets/hero.png', kind: 'image' }),
|
||||
file({ name: 'top.html', kind: 'html' }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<DesignFilesPanel
|
||||
projectId="test-project"
|
||||
files={files}
|
||||
liveArtifacts={[]}
|
||||
onRefreshFiles={vi.fn()}
|
||||
onOpenFile={vi.fn()}
|
||||
onOpenLiveArtifact={vi.fn()}
|
||||
onRenameFile={vi.fn()}
|
||||
onDeleteFile={vi.fn()}
|
||||
onDeleteFiles={async (names) => {
|
||||
deleteSpy(names);
|
||||
setFiles((current) => current.filter((candidate) => !names.includes(candidate.name)));
|
||||
}}
|
||||
onUpload={vi.fn()}
|
||||
onUploadFiles={vi.fn()}
|
||||
onPaste={vi.fn()}
|
||||
onNewSketch={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
|
||||
fireEvent.click(document.querySelector('.df-dir-row .df-row-name-btn')!);
|
||||
expect(document.querySelector('.df-breadcrumb-current')?.textContent).toBe('assets');
|
||||
|
||||
fireEvent.click(screen.getByTestId('design-file-row-assets/logo.png').querySelector('.df-row-check')!);
|
||||
fireEvent.click(screen.getByTestId('design-file-row-assets/hero.png').querySelector('.df-row-check')!);
|
||||
fireEvent.click(screen.getByTestId('design-files-batch-delete'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteSpy).toHaveBeenCalledWith(['assets/logo.png', 'assets/hero.png']);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.df-breadcrumbs')).toBeNull();
|
||||
});
|
||||
expect(screen.getByTestId('design-file-row-top.html')).toBeTruthy();
|
||||
expect(document.querySelectorAll('.df-file-row.selected').length).toBe(0);
|
||||
});
|
||||
|
||||
it('does not show the select-all header as checked when the page contains only directory rows', () => {
|
||||
renderPanel([
|
||||
file({ name: 'assets/logo.png', kind: 'image' }),
|
||||
]);
|
||||
|
||||
const headerCheck = document.querySelector('.df-th-check .df-row-check');
|
||||
expect(headerCheck?.textContent).toBe('☐');
|
||||
});
|
||||
|
||||
it('shows persisted empty folders', () => {
|
||||
renderPanel([], {
|
||||
folders: [{ name: 'assets', path: 'assets', type: 'dir', size: 0, mtime: Date.now() }],
|
||||
});
|
||||
|
||||
const dirRows = document.querySelectorAll('.df-dir-row');
|
||||
expect(dirRows.length).toBe(1);
|
||||
expect(dirRows[0]!.textContent).toContain('assets');
|
||||
expect(dirRows[0]!.textContent).toContain('0');
|
||||
expect(screen.queryByTestId('design-files-empty')).toBeNull();
|
||||
});
|
||||
|
||||
it('creates a folder in the current directory', async () => {
|
||||
const promptSpy = vi.spyOn(window, 'prompt').mockImplementation(() => {
|
||||
throw new Error('prompt should not be used for folder creation');
|
||||
});
|
||||
const onCreateFolder = vi.fn(async () => ({
|
||||
name: 'assets/untitled-folder',
|
||||
path: 'assets/untitled-folder',
|
||||
type: 'dir' as const,
|
||||
size: 0 as const,
|
||||
mtime: Date.now(),
|
||||
}));
|
||||
|
||||
renderPanel([file({ name: 'assets/logo.png', kind: 'image' })], { onCreateFolder });
|
||||
fireEvent.click(document.querySelector('.df-dir-row .df-row-name-btn')!);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Folder' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCreateFolder).toHaveBeenCalledWith('assets/untitled-folder');
|
||||
});
|
||||
expect(promptSpy).not.toHaveBeenCalled();
|
||||
expect(document.querySelector('.df-breadcrumb-current')?.textContent).toBe('untitled-folder');
|
||||
promptSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('searches nested file paths across the project', () => {
|
||||
renderPanel([
|
||||
file({ name: 'assets/logo.png', kind: 'image' }),
|
||||
file({ name: 'top.html', kind: 'html' }),
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search files…' }));
|
||||
fireEvent.change(screen.getByLabelText('Search files…'), { target: { value: 'logo' } });
|
||||
|
||||
expect(screen.getByTestId('design-file-row-assets/logo.png')).toBeTruthy();
|
||||
expect(screen.queryByTestId('design-file-row-top.html')).toBeNull();
|
||||
expect(screen.getByText('assets/logo.png')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps Design Files search compact until focused and clearable', () => {
|
||||
renderPanel([
|
||||
file({ name: 'assets/logo.png', kind: 'image' }),
|
||||
file({ name: 'top.html', kind: 'html' }),
|
||||
]);
|
||||
|
||||
expect(document.querySelector('.df-search-control')?.className).not.toContain('is-expanded');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Search files…' }));
|
||||
const input = screen.getByLabelText('Search files…');
|
||||
fireEvent.change(input, { target: { value: 'logo' } });
|
||||
|
||||
expect(document.querySelector('.df-search-control')?.className).toContain('is-expanded');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
expect(screen.getByTestId('design-file-row-top.html')).toBeTruthy();
|
||||
|
||||
fireEvent.blur(input, { relatedTarget: document.body });
|
||||
expect(document.querySelector('.df-search-control')?.className).not.toContain('is-expanded');
|
||||
});
|
||||
|
||||
it('accepts dropped files anywhere on the design files panel', async () => {
|
||||
const upload = new File(['hello'], 'hello.txt', { type: 'text/plain' });
|
||||
const { container, onUploadFiles } = renderPanel([]);
|
||||
|
||||
fireEvent.drop(container.querySelector('.df-panel')!, {
|
||||
dataTransfer: {
|
||||
types: ['Files'],
|
||||
files: [upload],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUploadFiles).toHaveBeenCalledWith([upload]);
|
||||
});
|
||||
});
|
||||
|
||||
it('moves dragged files into a folder row', async () => {
|
||||
const data = new Map<string, string>();
|
||||
const dataTransfer = {
|
||||
types: [] as string[],
|
||||
effectAllowed: '',
|
||||
dropEffect: '',
|
||||
setData(type: string, value: string) {
|
||||
if (!this.types.includes(type)) this.types.push(type);
|
||||
data.set(type, value);
|
||||
},
|
||||
getData(type: string) {
|
||||
return data.get(type) ?? '';
|
||||
},
|
||||
};
|
||||
const onRenameFile = vi.fn(async (_from: string, to: string) => file({ name: to, kind: 'image' }));
|
||||
renderPanel([
|
||||
file({ name: 'assets/.keep', kind: 'text' }),
|
||||
file({ name: 'logo.png', kind: 'image' }),
|
||||
], { onRenameFile });
|
||||
|
||||
fireEvent.dragStart(screen.getByTestId('design-file-row-logo.png'), { dataTransfer });
|
||||
fireEvent.dragOver(document.querySelector('.df-dir-row')!, { dataTransfer });
|
||||
fireEvent.drop(document.querySelector('.df-dir-row')!, { dataTransfer });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRenameFile).toHaveBeenCalledWith('logo.png', 'assets/logo.png');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Red spec for bug #3a: view state (pageSize, sortKey, sortDir, kindFilter)
|
||||
// resets on navigation because the component remounts via key={projectId}.
|
||||
// These tests must go RED on origin/main and GREEN after the fix.
|
||||
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DesignFilesPanel } from '../../src/components/DesignFilesPanel';
|
||||
import type { ProjectFile, ProjectFileKind } from '../../src/types';
|
||||
|
||||
// Minimal localStorage stub mirroring the pattern in state/config.test.ts
|
||||
const store = new Map<string, string>();
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
store.delete(key);
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store.clear();
|
||||
}),
|
||||
});
|
||||
|
||||
function file(name: string, kind: ProjectFileKind = 'html', mtime = Date.now()): ProjectFile {
|
||||
return { path: name, name, type: 'file', size: 1024, mtime, kind, mime: 'text/html' };
|
||||
}
|
||||
|
||||
function generateFiles(count: number): ProjectFile[] {
|
||||
const kinds: ProjectFileKind[] = ['html', 'image', 'sketch', 'text', 'code', 'pdf'];
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const kind = kinds[i % kinds.length]!;
|
||||
return file(`file-${i + 1}.html`, kind, Date.now() - i * 60_000);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPanel(
|
||||
files: ProjectFile[],
|
||||
projectId = 'proj-a',
|
||||
) {
|
||||
return render(
|
||||
<DesignFilesPanel
|
||||
projectId={projectId}
|
||||
files={files}
|
||||
liveArtifacts={[]}
|
||||
onRefreshFiles={vi.fn()}
|
||||
onOpenFile={vi.fn()}
|
||||
onOpenLiveArtifact={vi.fn()}
|
||||
onRenameFile={vi.fn()}
|
||||
onDeleteFile={vi.fn()}
|
||||
onDeleteFiles={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onUploadFiles={vi.fn()}
|
||||
onPaste={vi.fn()}
|
||||
onNewSketch={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function getPerPageSelect(container: HTMLElement): HTMLSelectElement {
|
||||
// The per-page select is the first select in the panel
|
||||
return container.querySelector<HTMLSelectElement>('.df-pagination-start select')!;
|
||||
}
|
||||
|
||||
function getSortBtn(container: HTMLElement, label: string): HTMLElement {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>('.df-th-btn')).find(
|
||||
(el) => el.textContent?.trim().startsWith(label),
|
||||
)!;
|
||||
}
|
||||
|
||||
describe('DesignFilesPanel view-state persistence', () => {
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
vi.mocked(localStorage.getItem).mockClear();
|
||||
vi.mocked(localStorage.setItem).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('restores pageSize from localStorage after remount', () => {
|
||||
const files = generateFiles(500);
|
||||
|
||||
// First mount: change page size to 60
|
||||
const first = renderPanel(files);
|
||||
const sel = getPerPageSelect(first.container);
|
||||
fireEvent.change(sel, { target: { value: '60' } });
|
||||
first.unmount();
|
||||
|
||||
// Second mount simulates navigation away and back (key={projectId} causes remount)
|
||||
const second = renderPanel(files);
|
||||
const restoredSel = getPerPageSelect(second.container);
|
||||
expect(restoredSel.value).toBe('60');
|
||||
});
|
||||
|
||||
it('restores sort key from localStorage after remount', () => {
|
||||
const files = generateFiles(50);
|
||||
|
||||
// First mount: click "Name" header to sort by name
|
||||
const first = renderPanel(files);
|
||||
fireEvent.click(getSortBtn(first.container, 'Name'));
|
||||
first.unmount();
|
||||
|
||||
// Second mount: "Name" column should show the sort arrow
|
||||
const second = renderPanel(files);
|
||||
const nameBtn = getSortBtn(second.container, 'Name');
|
||||
expect(nameBtn.textContent).toContain('↑');
|
||||
});
|
||||
|
||||
it('restores sort direction from localStorage after remount', () => {
|
||||
const files = generateFiles(50);
|
||||
|
||||
// First mount: click "Name" twice to get desc
|
||||
const first = renderPanel(files);
|
||||
fireEvent.click(getSortBtn(first.container, 'Name'));
|
||||
fireEvent.click(getSortBtn(first.container, 'Name'));
|
||||
first.unmount();
|
||||
|
||||
// Second mount: Name column should show desc arrow
|
||||
const second = renderPanel(files);
|
||||
const nameBtn = getSortBtn(second.container, 'Name');
|
||||
expect(nameBtn.textContent).toContain('↓');
|
||||
});
|
||||
|
||||
it('restores kindFilter from localStorage after remount', () => {
|
||||
// Files with mixed kinds so the filter button appears
|
||||
const files = [
|
||||
file('a.html', 'html'),
|
||||
file('b.png', 'image'),
|
||||
file('c.txt', 'text'),
|
||||
];
|
||||
|
||||
// First mount: open the filter popover and check 'HTML page'
|
||||
const first = renderPanel(files);
|
||||
const filterTrigger = first.container.querySelector<HTMLElement>('.df-kind-filter-trigger');
|
||||
expect(filterTrigger).not.toBeNull();
|
||||
fireEvent.click(filterTrigger!);
|
||||
const checkboxes = first.container.querySelectorAll<HTMLInputElement>(
|
||||
'.df-kind-filter-list input[type="checkbox"]',
|
||||
);
|
||||
// Check the first checkbox (HTML)
|
||||
if (checkboxes[0]) fireEvent.click(checkboxes[0]);
|
||||
first.unmount();
|
||||
|
||||
// Second mount: filter button should show active state (a kind is selected)
|
||||
const second = renderPanel(files);
|
||||
const trigger = second.container.querySelector('.df-kind-filter-trigger');
|
||||
expect(trigger?.classList.contains('active')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not bleed pageSize from one project into another', () => {
|
||||
const files = generateFiles(500);
|
||||
|
||||
// Project A: set page size 60
|
||||
const first = renderPanel(files, 'proj-a');
|
||||
fireEvent.change(getPerPageSelect(first.container), { target: { value: '60' } });
|
||||
first.unmount();
|
||||
|
||||
// Project B: should still have the default (30), not project A's setting
|
||||
const second = renderPanel(files, 'proj-b');
|
||||
expect(getPerPageSelect(second.container).value).toBe('30');
|
||||
});
|
||||
|
||||
it('writes view state to localStorage on pageSize change', () => {
|
||||
const files = generateFiles(500);
|
||||
const { container } = renderPanel(files);
|
||||
|
||||
fireEvent.change(getPerPageSelect(container), { target: { value: '45' } });
|
||||
|
||||
expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/od:design-files:view-state/),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to default pageSize when stored value is not a supported option', () => {
|
||||
const files = generateFiles(500);
|
||||
|
||||
// Seed localStorage with an unsupported value (fractional, out-of-set integer)
|
||||
for (const bad of [0.5, 17, 999, -1, 0]) {
|
||||
vi.mocked(localStorage.getItem).mockReturnValueOnce(JSON.stringify({ pageSize: bad }));
|
||||
const { container } = renderPanel(files);
|
||||
expect(getPerPageSelect(container).value).toBe('30');
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { HomeView } from '../../src/components/HomeView';
|
||||
import { isOpenDesignHostAvailable, pickHostWorkingDir } from '@open-design/host';
|
||||
import { openFolderDialog } from '../../src/providers/registry';
|
||||
|
||||
vi.mock('@open-design/host', async () => {
|
||||
const actual = await vi.importActual<typeof import('@open-design/host')>('@open-design/host');
|
||||
return {
|
||||
...actual,
|
||||
isOpenDesignHostAvailable: vi.fn(),
|
||||
pickHostWorkingDir: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/providers/registry', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
|
||||
'../../src/providers/registry',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
openFolderDialog: vi.fn(),
|
||||
fetchProjectFiles: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedIsHostAvailable = vi.mocked(isOpenDesignHostAvailable);
|
||||
const mockedPickHostWorkingDir = vi.mocked(pickHostWorkingDir);
|
||||
const mockedOpenFolderDialog = vi.mocked(openFolderDialog);
|
||||
|
||||
function renderHome() {
|
||||
return render(
|
||||
<HomeView
|
||||
projects={[]}
|
||||
onSubmit={() => undefined}
|
||||
onOpenProject={() => undefined}
|
||||
onViewAllProjects={() => undefined}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('HomeView working-dir picker host fallback', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedOpenFolderDialog.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Regression for the "host unavailable + desktop gate active" case. When the
|
||||
// desktop host is present but cannot mint a working-dir token (mixed-version
|
||||
// upgrade where the preload lacks project.pickWorkingDir, or a host error),
|
||||
// we must NOT fall back to the browser folder dialog: that path yields a raw
|
||||
// path with no host token, so the later working-dir POST would be rejected by
|
||||
// the desktop auth gate and surface as a confusing late create-time failure.
|
||||
it('surfaces the host error instead of falling back to the browser dialog when the host pick fails', async () => {
|
||||
mockedIsHostAvailable.mockReturnValue(true);
|
||||
mockedPickHostWorkingDir.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: 'host build does not support pickWorkingDir',
|
||||
});
|
||||
|
||||
renderHome();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose folder' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Couldn't open the folder picker/i)).toBeTruthy();
|
||||
});
|
||||
expect(mockedOpenFolderDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The pure web path (no desktop host) has no token gate, so the raw browser
|
||||
// folder path is the expected input and the dialog fallback is correct.
|
||||
it('uses the browser folder dialog on the pure web path', async () => {
|
||||
mockedIsHostAvailable.mockReturnValue(false);
|
||||
mockedOpenFolderDialog.mockResolvedValue('/Users/me/web-folder');
|
||||
|
||||
renderHome();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose folder' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedOpenFolderDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(mockedPickHostWorkingDir).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// An explicit cancel of the host picker must not pop a second dialog.
|
||||
it('does not fall back to the browser dialog when the user cancels the host picker', async () => {
|
||||
mockedIsHostAvailable.mockReturnValue(true);
|
||||
mockedPickHostWorkingDir.mockResolvedValue({ ok: false, canceled: true });
|
||||
|
||||
renderHome();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose folder' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedPickHostWorkingDir).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(mockedOpenFolderDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,18 @@ describe('WorkingDirPill', () => {
|
||||
expect(screen.getByTestId('working-dir-pill-menu')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders only the folder name for Windows backslash paths', () => {
|
||||
render(
|
||||
<WorkingDirPill
|
||||
projectId="project-1"
|
||||
resolvedDir="C:\\work\\repo"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('working-dir-pill-trigger').textContent).toContain('repo');
|
||||
expect(screen.getByTestId('working-dir-pill-trigger').textContent).not.toContain('C:\\work');
|
||||
});
|
||||
|
||||
it('opens the project directory through the desktop host bridge', async () => {
|
||||
const openPath = vi.fn(async () => ({ ok: true as const }));
|
||||
restoreHost = installMockOpenDesignHost({
|
||||
|
||||
@@ -63,6 +63,24 @@ export type OpenDesignHostProjectReplaceWorkingDirResult =
|
||||
}
|
||||
| OpenDesignHostFailure;
|
||||
|
||||
export type OpenDesignHostPickWorkingDirSuccess = {
|
||||
baseDir: string;
|
||||
ok: true;
|
||||
// Single-use HMAC token (minted by the host main process for `baseDir`)
|
||||
// that the renderer threads into POST /api/projects/:id/working-dir once
|
||||
// the project exists. Lets the Home flow pick a folder before the project
|
||||
// is created without exposing the daemon's desktop-auth gate.
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type OpenDesignHostPickWorkingDirResult =
|
||||
| OpenDesignHostPickWorkingDirSuccess
|
||||
| {
|
||||
canceled: true;
|
||||
ok: false;
|
||||
}
|
||||
| OpenDesignHostFailure;
|
||||
|
||||
export type OpenDesignHostPdfPrintOptions = {
|
||||
deck?: boolean;
|
||||
};
|
||||
@@ -229,6 +247,9 @@ export type OpenDesignHostBridge = {
|
||||
project: {
|
||||
pickAndImport(init?: OpenDesignHostProjectImportInit): Promise<OpenDesignHostProjectImportResult>;
|
||||
pickAndReplaceWorkingDir(projectId: string): Promise<OpenDesignHostProjectReplaceWorkingDirResult>;
|
||||
// Optional so older host builds still satisfy the bridge shape; callers
|
||||
// must feature-detect before invoking.
|
||||
pickWorkingDir?(): Promise<OpenDesignHostPickWorkingDirResult>;
|
||||
};
|
||||
shell: {
|
||||
openExternal(url: string): Promise<OpenDesignHostActionResult>;
|
||||
@@ -381,6 +402,27 @@ export function normalizeOpenDesignHostProjectReplaceWorkingDirResult(
|
||||
return { baseDir, entryFile, ok: true };
|
||||
}
|
||||
|
||||
export function normalizeOpenDesignHostPickWorkingDirResult(
|
||||
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 };
|
||||
const reason = typeof input.reason === "string" && input.reason.length > 0
|
||||
? input.reason
|
||||
: "unknown failure";
|
||||
return failure(reason, 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 candidateFromScope(scope: OpenDesignHostGlobalScope): unknown {
|
||||
if (OPEN_DESIGN_HOST_GLOBAL in scope) return scope[OPEN_DESIGN_HOST_GLOBAL];
|
||||
const windowValue = scope.window;
|
||||
@@ -479,6 +521,25 @@ export async function pickAndReplaceHostProjectWorkingDir(
|
||||
}
|
||||
}
|
||||
|
||||
// Picks a folder via the host's native dialog and returns the chosen path
|
||||
// plus a single-use token, WITHOUT touching any project. The Home flow uses
|
||||
// this to let the user choose a working directory before the project exists;
|
||||
// the token is later spent on POST /api/projects/:id/working-dir.
|
||||
export async function pickHostWorkingDir(
|
||||
scope: OpenDesignHostGlobalScope = globalThis,
|
||||
): Promise<OpenDesignHostPickWorkingDirResult> {
|
||||
const host = getOpenDesignHost(scope);
|
||||
if (host == null) return unavailable("Open Design host is not available");
|
||||
if (typeof host.project.pickWorkingDir !== "function") {
|
||||
return unavailable("host build does not support pickWorkingDir");
|
||||
}
|
||||
try {
|
||||
return await host.project.pickWorkingDir();
|
||||
} catch (error) {
|
||||
return unavailable(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function printHostPdf(
|
||||
html: string,
|
||||
nonce?: string,
|
||||
|
||||
Reference in New Issue
Block a user