diff --git a/apps/web/src/components/CreateGitHubProjectFields.tsx b/apps/web/src/components/CreateGitHubProjectFields.tsx index 87faf1a6..ae31ed73 100644 --- a/apps/web/src/components/CreateGitHubProjectFields.tsx +++ b/apps/web/src/components/CreateGitHubProjectFields.tsx @@ -27,8 +27,8 @@ export function CreateGitHubProjectFields(props: { readonly submitting: boolean; readonly onRepositoryChange: (value: string) => void; readonly onDestinationParentChange: (value: string) => void; + readonly onDestinationBrowse: () => void; readonly onDirectoryNameChange: (value: string) => void; - readonly onBrowse: () => void; readonly onSubmitKeyDown: (event: KeyboardEvent) => void; }) { const { t } = useI18n(); @@ -98,8 +98,16 @@ export function CreateGitHubProjectFields(props: {
- - - {props.isElectron ? ( - - ) : null} +
diff --git a/apps/web/src/components/CreateProjectDialog.tsx b/apps/web/src/components/CreateProjectDialog.tsx index 4e7fa9f5..1b8e4fef 100644 --- a/apps/web/src/components/CreateProjectDialog.tsx +++ b/apps/web/src/components/CreateProjectDialog.tsx @@ -42,6 +42,7 @@ import { } from "./ui/dialog"; import { InputGroup, InputGroupAddon, InputGroupInput } from "./ui/input-group"; import { CentralIcon } from "~/lib/central-icons"; +import { FolderBrowserSheet } from "./FolderBrowserSheet"; // Inputs share one fixed height + radius so every control in the dialog reads // as the same size (mirrors EditProfileDialog's field styling). @@ -120,6 +121,7 @@ export function CreateProjectDialog(props: { */ const [pickedPath, setPickedPath] = useState(null); const [isPickingFolder, setIsPickingFolder] = useState(false); + const [isDestinationBrowserOpen, setIsDestinationBrowserOpen] = useState(false); const [isDropTarget, setIsDropTarget] = useState(false); const [submitting, setSubmitting] = useState(false); const [formError, setFormError] = useState(null); @@ -151,6 +153,7 @@ export function CreateProjectDialog(props: { activeOperationIdRef.current = null; setPickedPath(null); setIsPickingFolder(false); + setIsDestinationBrowserOpen(false); setIsDropTarget(false); setSubmitting(false); setFormError(null); @@ -499,7 +502,7 @@ export function CreateProjectDialog(props: { setDirectoryNameEdited(true); setFormError(null); }} - onBrowse={() => void handleBrowse()} + onDestinationBrowse={() => setIsDestinationBrowserOpen(true)} onSubmitKeyDown={submitOnEnter} /> )} @@ -544,6 +547,12 @@ export function CreateProjectDialog(props: { + ); } diff --git a/apps/web/src/components/FolderBrowserSheet.tsx b/apps/web/src/components/FolderBrowserSheet.tsx new file mode 100644 index 00000000..e31255b7 --- /dev/null +++ b/apps/web/src/components/FolderBrowserSheet.tsx @@ -0,0 +1,157 @@ +import { useCallback, useEffect, useState } from "react"; +import type { FilesystemBrowseResult } from "@harnessos/contracts"; + +import { readNativeApi } from "../nativeApi"; +import { ArrowLeftIcon, CheckIcon, LoaderCircleIcon } from "~/lib/icons"; +import { cn } from "~/lib/utils"; +import { useI18n } from "../i18n"; +import { FolderClosed } from "./FolderClosed"; +import { Sheet, SheetFooter, SheetHeader, SheetPanel, SheetPopup, SheetTitle } from "./ui/sheet"; +import { Button } from "./ui/button"; + +function trimDirectoryPath(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return "."; + if (trimmed === "/" || /^[A-Za-z]:[\\/]?$/.test(trimmed)) return trimmed; + return trimmed.replace(/[\\/]+$/, ""); +} + +function trailingSeparator(value: string): string { + return value.includes("\\") ? "\\" : "/"; +} + +function browsePath(value: string): string { + const normalized = trimDirectoryPath(value); + if (normalized === "/" || /^[A-Za-z]:[\\/]$/.test(normalized)) return normalized; + return `${normalized}${trailingSeparator(normalized)}`; +} + +function parentPath(value: string): string | null { + const normalized = trimDirectoryPath(value); + if (normalized === "." || normalized === "/" || /^[A-Za-z]:[\\/]?$/.test(normalized)) { + return null; + } + const separatorIndex = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")); + if (separatorIndex < 0) return null; + if (separatorIndex === 0) return normalized.startsWith("/") ? "/" : null; + return normalized.slice(0, separatorIndex); +} + +export function FolderBrowserSheet(props: { + readonly open: boolean; + readonly initialPath: string; + readonly onOpenChange: (open: boolean) => void; + readonly onSelect: (path: string) => void; +}) { + const { t } = useI18n(); + const [currentPath, setCurrentPath] = useState(() => browsePath(props.initialPath)); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (props.open) { + setCurrentPath(browsePath(props.initialPath)); + setResult(null); + setError(null); + } + }, [props.initialPath, props.open]); + + const load = useCallback( + async (path: string) => { + const api = readNativeApi(); + if (!api) { + setError(t("project.folderBrowserUnavailable")); + return; + } + setLoading(true); + setError(null); + try { + const next = await api.filesystem.browse({ partialPath: browsePath(path) }); + setResult(next); + } catch (cause) { + setResult(null); + setError(cause instanceof Error ? cause.message : t("project.folderBrowserLoadFailed")); + } finally { + setLoading(false); + } + }, + [t], + ); + + useEffect(() => { + if (!props.open) return; + void load(currentPath); + }, [currentPath, load, props.open]); + + const selectCurrent = () => { + props.onSelect(trimDirectoryPath(currentPath)); + props.onOpenChange(false); + }; + + const parent = parentPath(currentPath); + return ( + + + + {t("project.folderBrowserTitle")} +

+ {trimDirectoryPath(currentPath)} +

+
+ + {parent ? ( + + ) : null} + {loading ? ( +
+ + {t("project.folderBrowserLoading")} +
+ ) : error ? ( +

+ {error} +

+ ) : result && result.entries.length > 0 ? ( +
+ {result.entries.map((entry) => ( + + ))} +
+ ) : ( +

+ {t("project.folderBrowserEmpty")} +

+ )} +
+ + + +
+
+ ); +} diff --git a/apps/web/src/i18n/messages/projects.ts b/apps/web/src/i18n/messages/projects.ts index 703d8661..b324423f 100644 --- a/apps/web/src/i18n/messages/projects.ts +++ b/apps/web/src/i18n/messages/projects.ts @@ -69,6 +69,13 @@ const EN_MESSAGES = { "project.added": "Project added", "project.appServerUnavailable": "The app server is unavailable. Reconnect and try again.", "project.folderPickerUnavailable": "Could not open the folder picker. Try again.", + "project.folderBrowserTitle": "Choose a folder", + "project.folderBrowserUp": "Parent folder", + "project.folderBrowserLoading": "Loading folders…", + "project.folderBrowserEmpty": "No subfolders", + "project.folderBrowserSelect": "Choose this folder", + "project.folderBrowserUnavailable": "Folder browsing is unavailable. Reconnect and try again.", + "project.folderBrowserLoadFailed": "Could not load this folder. Try again.", "project.pathRequired": "Type a folder path, or drop a folder above.", "project.syncPending": "The project was added, but it has not appeared in the sidebar yet. Try again in a moment.", @@ -353,6 +360,13 @@ const ZH_CN_MESSAGES = { "project.added": "项目已添加", "project.appServerUnavailable": "应用服务暂不可用。请重新连接后重试。", "project.folderPickerUnavailable": "无法打开文件夹选择器。请重试。", + "project.folderBrowserTitle": "选择文件夹", + "project.folderBrowserUp": "返回上一级", + "project.folderBrowserLoading": "正在加载文件夹…", + "project.folderBrowserEmpty": "没有子文件夹", + "project.folderBrowserSelect": "选择此文件夹", + "project.folderBrowserUnavailable": "无法浏览文件夹。请重新连接后重试。", + "project.folderBrowserLoadFailed": "无法加载此文件夹,请重试。", "project.pathRequired": "请输入文件夹路径,或把文件夹拖到上方。", "project.syncPending": "项目已添加,但尚未显示在侧栏中。请稍后重试。", "project.githubSyncPending": "GitHub 项目已添加,但尚未显示在侧栏中。请稍后重试。", diff --git a/scripts/check-public-identity.mjs b/scripts/check-public-identity.mjs index 37492612..70dd0626 100644 --- a/scripts/check-public-identity.mjs +++ b/scripts/check-public-identity.mjs @@ -14,10 +14,6 @@ const persistedMigrationPaths = new Set([ "apps/server/src/persistence/Migrations.integration.test.ts", ]); const persistedMigrationToken = `${formerWorkingName}InitialSchema`; -// Adoption records, mission evidence and source-validation fixtures are -// internal provenance, not shipped product surfaces. Their exact historical -// names must remain auditable and are intentionally excluded from the public -// identity scan. const internalProvenancePrefixes = ["missions/", "source-adoptions.json"]; const internalValidationPaths = new Set([ "apps/desktop/scripts/source-desktop-launch.test.mjs",