Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 16 additions & 13 deletions apps/web/src/components/CreateGitHubProjectFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement>) => void;
}) {
const { t } = useI18n();
Expand Down Expand Up @@ -98,7 +98,12 @@ export function CreateGitHubProjectFields(props: {
</label>
<div className="flex items-center gap-2">
<InputGroup className={cn(PROJECT_DIALOG_FIELD_CONTROL_CLASS_NAME, "min-w-0 flex-1")}>
<InputGroupAddon className="w-10 self-stretch border-e border-foreground/12 ps-0">
<InputGroupAddon
className="w-10 cursor-pointer self-stretch border-e border-foreground/12 ps-0"
role="button"
aria-label={t("project.browse")}
onClick={props.onDestinationBrowse}
>
<FolderClosed className="size-4 text-muted-foreground/70" aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
Expand All @@ -112,17 +117,15 @@ export function CreateGitHubProjectFields(props: {
onKeyDown={props.onSubmitKeyDown}
/>
</InputGroup>
{props.isElectron ? (
<Button
type="button"
variant="outline"
className={cn(PROJECT_DIALOG_FIELD_CONTROL_CLASS_NAME, "shrink-0 px-3")}
disabled={props.isPickingFolder || props.submitting}
onClick={props.onBrowse}
>
{t("project.browse")}
</Button>
) : null}
<Button
type="button"
variant="outline"
className={cn(PROJECT_DIALOG_FIELD_CONTROL_CLASS_NAME, "shrink-0 px-3")}
disabled={props.isPickingFolder || props.submitting}
onClick={props.onDestinationBrowse}
>
{t("project.browse")}
</Button>
</div>
</div>

Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/components/CreateProjectDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
} 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).
Expand Down Expand Up @@ -120,6 +121,7 @@
*/
const [pickedPath, setPickedPath] = useState<string | null>(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<string | null>(null);
Expand Down Expand Up @@ -151,6 +153,7 @@
activeOperationIdRef.current = null;
setPickedPath(null);
setIsPickingFolder(false);
setIsDestinationBrowserOpen(false);
setIsDropTarget(false);
setSubmitting(false);
setFormError(null);
Expand Down Expand Up @@ -375,7 +378,7 @@
// hand-editing the path afterwards puts the box back in its idle state.
const pickedFolderName =
pickedPath !== null && trimmedPath === pickedPath
? (pickedPath.split(/[/\\]/).filter(Boolean).at(-1) ?? pickedPath)

Check warning on line 381 in apps/web/src/components/CreateProjectDialog.tsx

View workflow job for this annotation

GitHub Actions / Linux static

eslint-plugin-unicorn(prefer-array-find)

Prefer `find` over filtering and accessing the first result.
: null;
const finalClonePath = joinProjectPath(trimmedDestinationParent, trimmedDirectoryName);

Expand Down Expand Up @@ -499,7 +502,7 @@
setDirectoryNameEdited(true);
setFormError(null);
}}
onBrowse={() => void handleBrowse()}
onDestinationBrowse={() => setIsDestinationBrowserOpen(true)}
onSubmitKeyDown={submitOnEnter}
/>
)}
Expand Down Expand Up @@ -544,6 +547,12 @@
</Button>
</DialogFooter>
</DialogPopup>
<FolderBrowserSheet
open={isDestinationBrowserOpen}
initialPath={destinationParent || props.defaultCloneParent}
onOpenChange={setIsDestinationBrowserOpen}
onSelect={applyDestinationParent}
/>
</Dialog>
);
}
157 changes: 157 additions & 0 deletions apps/web/src/components/FolderBrowserSheet.tsx
Original file line number Diff line number Diff line change
@@ -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<FilesystemBrowseResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Sheet open={props.open} onOpenChange={props.onOpenChange}>
<SheetPopup side="right">
<SheetHeader>
<SheetTitle>{t("project.folderBrowserTitle")}</SheetTitle>
<p
className="truncate text-sm text-muted-foreground"
title={trimDirectoryPath(currentPath)}
>
{trimDirectoryPath(currentPath)}
</p>
</SheetHeader>
<SheetPanel className="pt-2">
{parent ? (
<button
type="button"
className="mb-2 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-foreground/5"
onClick={() => setCurrentPath(browsePath(parent))}
>
<ArrowLeftIcon className="size-4 text-muted-foreground" />
{t("project.folderBrowserUp")}
</button>
) : null}
{loading ? (
<div className="flex items-center gap-2 px-3 py-4 text-sm text-muted-foreground">
<LoaderCircleIcon className="size-4 animate-spin" />
{t("project.folderBrowserLoading")}
</div>
) : error ? (
<p role="alert" className="px-3 py-4 text-sm text-destructive">
{error}
</p>
) : result && result.entries.length > 0 ? (
<div className="space-y-1">
{result.entries.map((entry) => (
<button
type="button"
key={entry.fullPath}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm",
"hover:bg-foreground/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60",
)}
onClick={() => setCurrentPath(browsePath(entry.fullPath))}
>
<FolderClosed className="size-4 text-muted-foreground" />
<span className="truncate">{entry.name}</span>
</button>
))}
</div>
) : (
<p className="px-3 py-4 text-sm text-muted-foreground">
{t("project.folderBrowserEmpty")}
</p>
)}
</SheetPanel>
<SheetFooter>
<Button variant="prominent" onClick={selectCurrent} disabled={loading || Boolean(error)}>
<CheckIcon className="size-4" />
{t("project.folderBrowserSelect")}
</Button>
</SheetFooter>
</SheetPopup>
</Sheet>
);
}
14 changes: 14 additions & 0 deletions apps/web/src/i18n/messages/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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 项目已添加,但尚未显示在侧栏中。请稍后重试。",
Expand Down
Loading