mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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.
This commit is contained in:
parent
7f72be13cf
commit
f71cdd3822
33 changed files with 267 additions and 90 deletions
|
|
@ -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 {',
|
||||
|
|
|
|||
|
|
@ -3133,6 +3133,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();');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
OpenDesignHostFailure,
|
||||
OpenDesignHostProjectImportResult,
|
||||
OpenDesignHostProjectReplaceWorkingDirResult,
|
||||
OpenDesignHostPickWorkingDirResult,
|
||||
OpenDesignHostUpdaterActionOptions,
|
||||
OpenDesignHostUpdaterStatusListener,
|
||||
OpenDesignHostUpdaterStatusSnapshot,
|
||||
|
|
@ -91,6 +92,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) {
|
||||
|
|
@ -156,6 +178,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 = {
|
||||
|
|
|
|||
|
|
@ -1045,6 +1045,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");
|
||||
for (const channel of UPDATER_IPC_CHANNELS) {
|
||||
|
|
@ -1098,7 +1099,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 };
|
||||
}
|
||||
|
|
@ -1141,7 +1142,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 };
|
||||
}
|
||||
|
|
@ -1158,6 +1159,27 @@ 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 };
|
||||
}
|
||||
const baseDir = result.filePaths[0].trim();
|
||||
if (baseDir.length === 0) {
|
||||
return { ok: false, reason: "picker returned an empty path" };
|
||||
}
|
||||
const token = mintImportToken(options.desktopAuthSecret, baseDir);
|
||||
return { baseDir, ok: true, token };
|
||||
});
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import {
|
|||
fetchPromptTemplates,
|
||||
fetchSkills,
|
||||
uploadProjectFiles,
|
||||
replaceProjectWorkingDir,
|
||||
} from './providers/registry';
|
||||
import { RUNS_CHANGED_EVENT, listProjectRuns } from './providers/daemon';
|
||||
import { navigate, useRoute } from './router';
|
||||
|
|
@ -832,6 +833,7 @@ function AppInner() {
|
|||
autoSendFirstMessage?: boolean;
|
||||
requestId?: string;
|
||||
pendingFiles?: File[];
|
||||
userWorkingDirToken?: string;
|
||||
},
|
||||
): Promise<boolean> => {
|
||||
// Honor an explicit `null` design system — the create panel defaults
|
||||
|
|
@ -903,6 +905,18 @@ function AppInner() {
|
|||
: {}),
|
||||
});
|
||||
}
|
||||
const userWorkingDir = input.metadata?.userWorkingDir;
|
||||
if (userWorkingDir) {
|
||||
try {
|
||||
await replaceProjectWorkingDir(
|
||||
result.project.id,
|
||||
userWorkingDir,
|
||||
input.userWorkingDirToken,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('Failed to set working directory for new project', userWorkingDir, err);
|
||||
}
|
||||
}
|
||||
trackProjectCreateResult(
|
||||
analytics.track,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -524,6 +524,7 @@ export function EntryShell({
|
|||
...(payload.attachments && payload.attachments.length > 0
|
||||
? { pendingFiles: payload.attachments }
|
||||
: {}),
|
||||
...(payload.workingDirToken ? { userWorkingDirToken: payload.workingDirToken } : {}),
|
||||
autoSendFirstMessage: true,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,9 @@ interface Props {
|
|||
contextItemCount: number;
|
||||
error: string | null;
|
||||
showActivePluginChip?: boolean;
|
||||
workingDir?: string | null;
|
||||
onPickWorkingDir?: () => void;
|
||||
onClearWorkingDir?: () => void;
|
||||
}
|
||||
|
||||
interface HomeHeroDesignSystemOption {
|
||||
|
|
@ -192,6 +195,9 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
|
|||
contextItemCount,
|
||||
error,
|
||||
showActivePluginChip = true,
|
||||
workingDir = null,
|
||||
onPickWorkingDir,
|
||||
onClearWorkingDir,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
|
|
@ -1078,6 +1084,31 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, 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}
|
||||
{activeCreateChip ? (
|
||||
<ActiveTypeChip chip={activeCreateChip} onClear={onClearActiveChip} />
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,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,
|
||||
|
|
@ -236,6 +237,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('');
|
||||
|
|
@ -926,6 +932,24 @@ 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;
|
||||
}
|
||||
const picked = await openFolderDialog();
|
||||
if (picked) {
|
||||
setWorkingDir(picked);
|
||||
setWorkingDirToken(null);
|
||||
}
|
||||
}
|
||||
|
||||
function updateActiveInputs(next: Record<string, unknown>) {
|
||||
if (!active) return;
|
||||
const normalized = active.mediaSurface
|
||||
|
|
@ -1271,6 +1295,8 @@ export function HomeView({
|
|||
contextMcpServers,
|
||||
contextConnectors,
|
||||
attachments: stagedFiles,
|
||||
...(workingDir ? { workingDir } : {}),
|
||||
...(workingDirToken ? { workingDirToken } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1330,6 +1356,12 @@ export function HomeView({
|
|||
onPickChip={pickChip}
|
||||
contextItemCount={contextItemCount}
|
||||
error={error}
|
||||
workingDir={workingDir}
|
||||
onPickWorkingDir={handlePickWorkingDir}
|
||||
onClearWorkingDir={() => {
|
||||
setWorkingDir(null);
|
||||
setWorkingDirToken(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<RecentProjectsStrip
|
||||
|
|
|
|||
|
|
@ -41,6 +41,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;
|
||||
// Files staged on Home before the project exists. App uploads them
|
||||
// into the created project's Design Files before the first auto-send.
|
||||
attachments?: File[];
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ import { AvatarMenu } from './AvatarMenu';
|
|||
import { HandoffButton } from './HandoffButton';
|
||||
import { ProjectDesignSystemPicker } from './ProjectDesignSystemPicker';
|
||||
import { ChatPane } from './ChatPane';
|
||||
import { WorkingDirPill } from './WorkingDirPill';
|
||||
import type { ChatSendMeta } from './ChatComposer';
|
||||
import {
|
||||
CritiqueTheaterMount,
|
||||
|
|
@ -577,6 +578,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[]>([]);
|
||||
|
|
@ -4513,6 +4518,28 @@ export function ProjectView({
|
|||
}}
|
||||
activePluginSnapshot={activePluginSnapshot}
|
||||
onCollapse={() => setWorkspaceFocused(true)}
|
||||
composerFooterAccessory={
|
||||
<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));
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="pane" data-testid="chat-pane-loading">
|
||||
|
|
@ -4543,6 +4570,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}
|
||||
files={projectFiles}
|
||||
liveArtifacts={liveArtifacts}
|
||||
filesRefreshKey={filesRefresh}
|
||||
|
|
|
|||
|
|
@ -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,28 +22,6 @@ 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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -540,7 +540,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}',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -496,7 +496,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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -540,7 +540,7 @@ export const zhCN: Dict = {
|
|||
'workingDirPicker.openUnavailable': '请在桌面版中打开此项目以显示文件夹。',
|
||||
'workingDirPicker.openFailed': '无法显示此文件夹',
|
||||
'workingDirPicker.showInFileManager': '在文件管理器中显示',
|
||||
'workingDirPicker.replace': '清空并替换目录…',
|
||||
'workingDirPicker.replace': '替换目录…',
|
||||
'workingDirPicker.recent': '最近使用的目录',
|
||||
'handoff.toTarget': '交付给 {target}',
|
||||
'handoff.openInTarget': '在 {target} 中打开',
|
||||
|
|
|
|||
|
|
@ -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} 中開啟',
|
||||
|
|
|
|||
|
|
@ -931,10 +931,11 @@
|
|||
.home-hero__foot-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: nowrap;
|
||||
overflow: visible;
|
||||
}
|
||||
.home-hero__attach {
|
||||
appearance: none;
|
||||
|
|
@ -966,17 +967,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);
|
||||
|
|
@ -1098,10 +1098,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);
|
||||
|
|
@ -1143,7 +1144,7 @@
|
|||
outline: none;
|
||||
}
|
||||
.home-hero__footer-option input {
|
||||
width: 150px;
|
||||
width: 100px;
|
||||
}
|
||||
.home-hero__footer-option--select {
|
||||
padding-right: 11px;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -213,6 +231,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>;
|
||||
|
|
@ -359,6 +380,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;
|
||||
|
|
@ -431,6 +473,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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue