authorgravatar for zied.essaber@gmail.comInaya Yousfi <zied.essaber@gmail.com> 2026-08-15 14:37:47+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:37:47+00:00
logec141c125726ae70f31f392e780afa8de446fdc4
tree1ccfd2751b0f3b7dc6a7da512ae10097647d4e74
parent2fc67623955be6c4e9f439c08ad752386dd7f375
signature Signed by PGP key B5690EEEBB952194

fix(clients): default clone destination to folder plus repo name (#5989)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>

8 files changed, 340 insertions(+), 30 deletions(-)

apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx+1
......@@ -6,6 +6,7 @@ type AddProjectDestinationRouteParams = {
66 readonly source?: string | string[];
77 readonly remoteUrl?: string | string[];
88 readonly repositoryTitle?: string | string[];
9 readonly repositoryName?: string | string[];
910};
1011
1112export function AddProjectDestinationRoute({
apps/mobile/src/features/projects/AddProjectScreen.tsx+80-20
......@@ -7,6 +7,9 @@ import {
77 canCreateProjectInEnvironment,
88 findExistingAddProject,
99 getAddProjectInitialQuery,
10 getCloneDestinationBrowsePath,
11 getCloneDestinationPath,
12 getCloneDirectoryName,
1013 resolveAddProjectPath,
1114 sortAddProjectProviderSources,
1215 type AddProjectRemoteSource,
......@@ -23,8 +26,8 @@ import {
2326} from "@t3tools/client-runtime/state/filesystem";
2427import {
2528 appendBrowsePathSegment,
26 ensureBrowseDirectoryPath,
2729 inferProjectTitleFromPath,
30 isWindowsPlatform,
2831} from "@t3tools/client-runtime/state/projects";
2932import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";
3033import { CommonActions, StackActions, useNavigation } from "@react-navigation/native";
......@@ -236,11 +239,18 @@ function ProjectPathInput(props: {
236239 );
237240}
238241
239function useBrowsePathInput(environment: EnvironmentOption | null) {
242// `pinnedDirectoryName` is the repository folder the clone destination keeps
243// appended to whatever folder the user browses to. The plain add-project flow
244// passes nothing, so it keeps proposing the browsed folder itself.
245function useBrowsePathInput(environment: EnvironmentOption | null, pinnedDirectoryName = "") {
240246 const environmentId = environment?.environmentId ?? null;
241247 const environmentBaseDirectory = environment?.baseDirectory ?? null;
248 const clonePathCaseSensitive = !isWindowsPlatform(environment?.platform ?? "");
242249 const [pathInput, commitPathInput] = useState(() =>
243 getAddProjectInitialQuery(environmentBaseDirectory),
250 getCloneDestinationPath(
251 getAddProjectInitialQuery(environmentBaseDirectory),
252 pinnedDirectoryName,
253 ),
244254 );
245255 const previousEnvironmentIdRef = useRef(environmentId);
246256 const environmentRuntime = useRemoteEnvironmentRuntime(environmentId);
......@@ -259,33 +269,60 @@ function useBrowsePathInput(environment: EnvironmentOption | null) {
259269 [browseNavigation],
260270 );
261271 const navigateToBrowsePath = useCallback(
262 async (path: string) => {
272 async (input: {
273 readonly browseDirectoryPath: string;
274 readonly selectedDirectoryName?: string;
275 }) => {
276 const selectedDirectoryPath = input.selectedDirectoryName
277 ? appendBrowsePathSegment(input.browseDirectoryPath, input.selectedDirectoryName)
278 : input.browseDirectoryPath;
279 const nextPathInput =
280 pinnedDirectoryName && input.selectedDirectoryName
281 ? getCloneDestinationBrowsePath({
282 browseDirectoryPath: input.browseDirectoryPath,
283 selectedDirectoryName: input.selectedDirectoryName,
284 cloneDirectoryName: pinnedDirectoryName,
285 caseSensitive: clonePathCaseSensitive,
286 })
287 : getCloneDestinationPath(selectedDirectoryPath, pinnedDirectoryName);
263288 setIsBrowseNavigating(true);
264289 const committed = await browseNavigation.run(
265290 async () => {
266291 if (environment && canPreloadBrowsePath(environmentRuntime?.connectionState)) {
267292 await loadBrowsePath({
268293 environmentId: environment.environmentId,
269 input: { partialPath: path },
294 input: { partialPath: selectedDirectoryPath },
270295 });
271296 }
272297 },
273 () => commitPathInput(path),
298 () => commitPathInput(nextPathInput),
274299 );
275300 if (committed) {
276301 setIsBrowseNavigating(false);
277302 }
278303 return committed;
279304 },
280 [browseNavigation, environment, environmentRuntime?.connectionState, loadBrowsePath],
305 [
306 browseNavigation,
307 clonePathCaseSensitive,
308 environment,
309 environmentRuntime?.connectionState,
310 loadBrowsePath,
311 pinnedDirectoryName,
312 ],
281313 );
282314
283315 useEffect(() => {
284316 if (environmentId !== null && environmentId !== previousEnvironmentIdRef.current) {
285317 previousEnvironmentIdRef.current = environmentId;
286 setPathInput(getAddProjectInitialQuery(environmentBaseDirectory));
318 setPathInput(
319 getCloneDestinationPath(
320 getAddProjectInitialQuery(environmentBaseDirectory),
321 pinnedDirectoryName,
322 ),
323 );
287324 }
288 }, [environmentBaseDirectory, environmentId, setPathInput]);
325 }, [environmentBaseDirectory, environmentId, pinnedDirectoryName, setPathInput]);
289326
290327 useEffect(
291328 () => () => {
......@@ -632,6 +669,7 @@ export function AddProjectRepositoryScreen(props: {
632669 source,
633670 remoteUrl,
634671 repositoryTitle: remoteUrl,
672 repositoryName: getCloneDirectoryName(remoteUrl),
635673 }),
636674 );
637675 setIsSubmitting(false);
......@@ -655,6 +693,7 @@ export function AddProjectRepositoryScreen(props: {
655693 source,
656694 remoteUrl: repository.sshUrl,
657695 repositoryTitle: repository.nameWithOwner,
696 repositoryName: getCloneDirectoryName(repository.nameWithOwner),
658697 }),
659698 );
660699 }
......@@ -698,7 +737,11 @@ function FolderBrowser(props: {
698737 readonly environment: EnvironmentOption;
699738 readonly pathInput: string;
700739 readonly setPathInput: (path: string) => void;
701 readonly navigateToBrowsePath: (path: string) => Promise<boolean>;
740 readonly navigateToBrowsePath: (input: {
741 readonly browseDirectoryPath: string;
742 readonly selectedDirectoryName?: string;
743 }) => Promise<boolean>;
744 readonly pinnedDirectoryName?: string;
702745}) {
703746 const accentColor = useThemeColor("--color-icon-muted");
704747 const browsePath = useMemo(
......@@ -717,9 +760,16 @@ function FolderBrowser(props: {
717760 input: browseInput,
718761 }),
719762 );
763 // A pinned repository folder does not exist yet, so filtering the listing by
764 // it would empty the folder picker. Anything the user typed still filters.
765 const pinnedDirectoryName = props.pinnedDirectoryName ?? "";
766 const pinnedDirectoryMatches = isWindowsPlatform(props.environment.platform)
767 ? browsePath.filterQuery.toLowerCase() === pinnedDirectoryName.toLowerCase()
768 : browsePath.filterQuery === pinnedDirectoryName;
769 const browseFilterQuery = pinnedDirectoryMatches ? "" : browsePath.filterQuery;
720770 const { visibleEntries: visibleBrowseEntries } = useMemo(
721 () => filterFilesystemBrowseEntries(browseState.data?.entries ?? [], browsePath.filterQuery),
722 [browsePath.filterQuery, browseState.data?.entries],
771 () => filterFilesystemBrowseEntries(browseState.data?.entries ?? [], browseFilterQuery),
772 [browseFilterQuery, browseState.data?.entries],
723773 );
724774
725775 return (
......@@ -747,7 +797,9 @@ function FolderBrowser(props: {
747797 right={null}
748798 onPress={() => {
749799 if (browsePath.parentPath) {
750 void props.navigateToBrowsePath(browsePath.parentPath);
800 void props.navigateToBrowsePath({
801 browseDirectoryPath: browsePath.parentPath,
802 });
751803 }
752804 }}
753805 />
......@@ -760,11 +812,10 @@ function FolderBrowser(props: {
760812 isFirst={index === 0 && !browsePath.canBrowseUp}
761813 right={null}
762814 onPress={() => {
763 const nextPath =
764 browsePath.directoryPath.length > 0
765 ? appendBrowsePathSegment(browsePath.directoryPath, entry.name)
766 : ensureBrowseDirectoryPath(entry.fullPath);
767 void props.navigateToBrowsePath(nextPath);
815 void props.navigateToBrowsePath({
816 browseDirectoryPath: browsePath.directoryPath,
817 selectedDirectoryName: entry.name,
818 });
768819 }}
769820 />
770821 ))}
......@@ -836,6 +887,7 @@ export function AddProjectDestinationScreen(props: {
836887 readonly environmentId?: string | string[];
837888 readonly remoteUrl?: string | string[];
838889 readonly repositoryTitle?: string | string[];
890 readonly repositoryName?: string | string[];
839891}) {
840892 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {
841893 reportFailure: false,
......@@ -844,8 +896,15 @@ export function AddProjectDestinationScreen(props: {
844896 const createProject = useCreateProject(environment);
845897 const remoteUrl = stringParam(props.remoteUrl);
846898 const repositoryTitle = stringParam(props.repositoryTitle);
847 const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } =
848 useBrowsePathInput(environment);
899 // A lookup derives this from "owner/repo", a pasted clone URL from its own
900 // last segment. Older links without the param keep the browsed folder.
901 // Trim once here: the path input and the folder-list filter must compare the
902 // same value, or a deep link with a padded param empties the folder picker.
903 const repositoryName = stringParam(props.repositoryName)?.trim() ?? "";
904 const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = useBrowsePathInput(
905 environment,
906 repositoryName,
907 );
849908 const [isSubmitting, setIsSubmitting] = useState(false);
850909 const [error, setError] = useState<string | null>(null);
851910
......@@ -918,6 +977,7 @@ export function AddProjectDestinationScreen(props: {
918977 navigateToBrowsePath={navigateToBrowsePath}
919978 pathInput={pathInput}
920979 setPathInput={setPathInput}
980 pinnedDirectoryName={repositoryName}
921981 />
922982 </>
923983 ) : (
apps/web/src/components/CommandPalette.logic.test.ts+37
......@@ -6,6 +6,7 @@ import {
66 buildBrowseGroups,
77 buildThreadActionItems,
88 enumerateCommandPaletteItems,
9 filterPinnedBrowseEntries,
910 filterCommandPaletteGroups,
1011 reduceCommandPaletteUiState,
1112 type CommandPaletteGroup,
......@@ -371,3 +372,39 @@ describe("buildBrowseGroups", () => {
371372 expect(actionSettled).toBe(true);
372373 });
373374});
375
376describe("filterPinnedBrowseEntries", () => {
377 const entries = [
378 { name: "repo", fullPath: "/projects/repo" },
379 { name: "work", fullPath: "/projects/work" },
380 ];
381
382 it("shows sibling folders without losing an existing pinned destination", () => {
383 expect(
384 filterPinnedBrowseEntries({
385 browseEntries: entries,
386 filterQuery: "repo",
387 pinnedDirectoryName: "repo",
388 caseSensitive: true,
389 }),
390 ).toEqual({ visibleEntries: entries, exactEntry: entries[0] });
391 });
392
393 it("matches an existing pinned destination without Windows casing", () => {
394 const windowsEntries = [
395 { name: "Repo", fullPath: "C:\\projects\\Repo" },
396 { name: "work", fullPath: "C:\\projects\\work" },
397 ];
398 expect(
399 filterPinnedBrowseEntries({
400 browseEntries: windowsEntries,
401 filterQuery: "repo",
402 pinnedDirectoryName: "repo",
403 caseSensitive: false,
404 }),
405 ).toEqual({
406 visibleEntries: windowsEntries,
407 exactEntry: windowsEntries[0],
408 });
409 });
410});
apps/web/src/components/CommandPalette.logic.ts+20
......@@ -3,6 +3,7 @@ import {
33 type KeybindingCommand,
44 THREAD_JUMP_KEYBINDING_COMMANDS,
55} from "@t3tools/contracts";
6import { filterFilesystemBrowseEntries } from "@t3tools/client-runtime/state/filesystem";
67import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";
78import * as Arr from "effect/Array";
89import * as Result from "effect/Result";
......@@ -397,6 +398,25 @@ export function buildBrowseGroups(input: {
397398 return [{ value: "directories", label: "Directories", items }];
398399}
399400
401export function filterPinnedBrowseEntries(input: {
402 browseEntries: ReadonlyArray<FilesystemBrowseEntry>;
403 filterQuery: string;
404 pinnedDirectoryName: string;
405 caseSensitive: boolean;
406}): ReturnType<typeof filterFilesystemBrowseEntries> {
407 const namesMatch = (left: string, right: string) =>
408 input.caseSensitive ? left === right : left.toLowerCase() === right.toLowerCase();
409 const visibleFilterQuery = namesMatch(input.filterQuery, input.pinnedDirectoryName)
410 ? ""
411 : input.filterQuery;
412 const { visibleEntries } = filterFilesystemBrowseEntries(input.browseEntries, visibleFilterQuery);
413 const exactEntry =
414 input.filterQuery.length > 0
415 ? (input.browseEntries.find((entry) => namesMatch(entry.name, input.filterQuery)) ?? null)
416 : null;
417 return { visibleEntries, exactEntry };
418}
419
400420export function getCommandPaletteMode(input: {
401421 currentView: CommandPaletteView | null;
402422 isBrowsing: boolean;
apps/web/src/components/CommandPalette.tsx+54-9
......@@ -1,7 +1,12 @@
11"use client";
22
33import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
4import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects";
4import {
5 canCreateProjectInEnvironment,
6 getCloneDestinationBrowsePath,
7 getCloneDestinationPath,
8 getCloneDirectoryName,
9} from "@t3tools/client-runtime/operations/projects";
510import { connectionStatusText } from "@t3tools/client-runtime/connection";
611import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
712import {
......@@ -109,6 +114,7 @@ import {
109114 type CommandPaletteSubmenuItem,
110115 type CommandPaletteView,
111116 filterCommandPaletteGroups,
117 filterPinnedBrowseEntries,
112118 getCommandPaletteInputPlaceholder,
113119 getCommandPaletteMode,
114120 ITEM_ICON_CLASS,
......@@ -807,6 +813,16 @@ function OpenCommandPaletteDialog(props: {
807813 );
808814 const isRemoteProjectCloneFlow = addProjectCloneFlow !== null;
809815 const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository";
816 // The destination step pins the repository folder onto the browsed path, so
817 // the proposed clone target is "<chosen folder>/<repo>" instead of the bare
818 // folder. A lookup reports "owner/repo"; a pasted clone URL falls back to its
819 // own last segment, minus ".git".
820 const pinnedCloneDirectoryName =
821 addProjectCloneFlow?.step === "confirm"
822 ? getCloneDirectoryName(
823 addProjectCloneFlow.repository?.nameWithOwner ?? addProjectCloneFlow.remoteUrl,
824 )
825 : "";
810826 const browsePath = useMemo(
811827 () => getFilesystemBrowsePath(query, browseEnvironmentPlatform, !isRemoteProjectRepositoryStep),
812828 [browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query],
......@@ -879,8 +895,16 @@ function OpenCommandPaletteDialog(props: {
879895 const isBrowsePending = browseQuery.isPending;
880896 const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES;
881897 const { visibleEntries: visibleBrowseEntries, exactEntry: exactBrowseEntry } = useMemo(
882 () => filterFilesystemBrowseEntries(browseEntries, browsePath.filterQuery),
883 [browseEntries, browsePath.filterQuery],
898 () =>
899 pinnedCloneDirectoryName
900 ? filterPinnedBrowseEntries({
901 browseEntries,
902 filterQuery: browsePath.filterQuery,
903 pinnedDirectoryName: pinnedCloneDirectoryName,
904 caseSensitive: !isWindowsPlatform(browseEnvironmentPlatform),
905 })
906 : filterFilesystemBrowseEntries(browseEntries, browsePath.filterQuery),
907 [browseEntries, browseEnvironmentPlatform, browsePath.filterQuery, pinnedCloneDirectoryName],
884908 );
885909
886910 const prefetchBrowsePath = useCallback(
......@@ -1793,7 +1817,10 @@ function OpenCommandPaletteDialog(props: {
17931817
17941818 const provider = remoteProjectSourceProvider(addProjectCloneFlow.source);
17951819 if (!provider) {
1796 const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId);
1820 const destinationPath = getCloneDestinationPath(
1821 getDefaultCloneParentPath(addProjectCloneFlow.environmentId),
1822 getCloneDirectoryName(rawRepository),
1823 );
17971824 setAddProjectCloneFlow({
17981825 step: "confirm",
17991826 environmentId: addProjectCloneFlow.environmentId,
......@@ -1830,7 +1857,10 @@ function OpenCommandPaletteDialog(props: {
18301857 return;
18311858 }
18321859 const repository = lookupResult.value;
1833 const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId);
1860 const destinationPath = getCloneDestinationPath(
1861 getDefaultCloneParentPath(addProjectCloneFlow.environmentId),
1862 getCloneDirectoryName(repository.nameWithOwner),
1863 );
18341864 setAddProjectCloneFlow({
18351865 step: "confirm",
18361866 environmentId: addProjectCloneFlow.environmentId,
......@@ -1906,7 +1936,14 @@ function OpenCommandPaletteDialog(props: {
19061936
19071937 const browseTo = useCallback(
19081938 async (name: string): Promise<void> => {
1909 const nextQuery = appendBrowsePathSegment(query, name);
1939 const nextQuery = pinnedCloneDirectoryName
1940 ? getCloneDestinationBrowsePath({
1941 browseDirectoryPath: browsePath.directoryPath,
1942 selectedDirectoryName: name,
1943 cloneDirectoryName: pinnedCloneDirectoryName,
1944 caseSensitive: !isWindowsPlatform(browseEnvironmentPlatform),
1945 })
1946 : appendBrowsePathSegment(query, name);
19101947 await browseNavigation.run(
19111948 () => prefetchBrowsePath(getBrowseDirectoryPath(nextQuery)),
19121949 () => {
......@@ -1916,7 +1953,14 @@ function OpenCommandPaletteDialog(props: {
19161953 },
19171954 );
19181955 },
1919 [browseNavigation, prefetchBrowsePath, query],
1956 [
1957 browseNavigation,
1958 browseEnvironmentPlatform,
1959 browsePath.directoryPath,
1960 pinnedCloneDirectoryName,
1961 prefetchBrowsePath,
1962 query,
1963 ],
19201964 );
19211965
19221966 const browseUp = useCallback(async (): Promise<void> => {
......@@ -1925,15 +1969,16 @@ function OpenCommandPaletteDialog(props: {
19251969 return;
19261970 }
19271971
1972 const nextQuery = getCloneDestinationPath(parentPath, pinnedCloneDirectoryName);
19281973 await browseNavigation.run(
19291974 () => prefetchBrowsePath(parentPath),
19301975 () => {
19311976 setHighlightedItemValue(null);
1932 setQuery(parentPath);
1977 setQuery(nextQuery);
19331978 setBrowseGeneration((generation) => generation + 1);
19341979 },
19351980 );
1936 }, [browseNavigation, browsePath.parentPath, prefetchBrowsePath]);
1981 }, [browseNavigation, browsePath.parentPath, pinnedCloneDirectoryName, prefetchBrowsePath]);
19371982
19381983 // Resolve the add-project path from browse data when available. When the
19391984 // query has a trailing separator (e.g. "~/projects/foo/"), parentPath is the
packages/client-runtime/src/operations/projects.test.ts+76
......@@ -13,6 +13,9 @@ import {
1313 canCreateProjectInEnvironment,
1414 findExistingAddProject,
1515 getAddProjectInitialQuery,
16 getCloneDestinationBrowsePath,
17 getCloneDestinationPath,
18 getCloneDirectoryName,
1619 resolveAddProjectPath,
1720 sortAddProjectProviderSources,
1821} from "./projects.ts";
......@@ -34,6 +37,79 @@ describe("add project shared logic", () => {
3437 expect(getAddProjectInitialQuery("C:\\work")).toBe("C:\\work\\");
3538 });
3639
40 it("derives the clone folder name from the repository name with owner", () => {
41 expect(getCloneDirectoryName("owner/repo")).toBe("repo");
42 expect(getCloneDirectoryName("org/project/repo")).toBe("repo");
43 expect(getCloneDirectoryName("repo")).toBe("repo");
44 expect(getCloneDirectoryName("owner/repo/")).toBe("repo");
45 expect(getCloneDirectoryName("")).toBe("");
46 expect(getCloneDirectoryName(null)).toBe("");
47 });
48
49 it("derives the clone folder name from any pasted clone URL", () => {
50 expect(getCloneDirectoryName("https://github.com/owner/repo.git")).toBe("repo");
51 expect(getCloneDirectoryName("https://github.com/owner/repo")).toBe("repo");
52 expect(getCloneDirectoryName("https://github.com/owner/repo/")).toBe("repo");
53 expect(getCloneDirectoryName("git@github.com:owner/repo.git")).toBe("repo");
54 expect(getCloneDirectoryName("ssh://git@github.com:22/owner/repo.git")).toBe("repo");
55 expect(getCloneDirectoryName("https://user@bitbucket.org/owner/repo.git")).toBe("repo");
56 expect(getCloneDirectoryName("https://dev.azure.com/org/project/_git/repo")).toBe("repo");
57 expect(getCloneDirectoryName("https://github.com/owner/repo.git?ref=main#readme")).toBe("repo");
58 expect(getCloneDirectoryName("/srv/git/repo.git")).toBe("repo");
59 expect(getCloneDirectoryName("C:\\src\\repo.git")).toBe("repo");
60 expect(getCloneDirectoryName("git@github.com:repo.git")).toBe("repo");
61 expect(getCloneDirectoryName(" https://github.com/owner/repo.git ")).toBe("repo");
62 });
63
64 it("keeps a numeric repository name that sits on a path", () => {
65 expect(getCloneDirectoryName("https://github.com/acme/123.git")).toBe("123");
66 expect(getCloneDirectoryName("https://github.com/acme/123")).toBe("123");
67 expect(getCloneDirectoryName("git@github.com:acme/123.git")).toBe("123");
68 });
69
70 it("proposes no clone folder for a link that names no repository", () => {
71 expect(getCloneDirectoryName("https://github.com/")).toBe("");
72 expect(getCloneDirectoryName("https://github.com")).toBe("");
73 expect(getCloneDirectoryName("git@github.com:")).toBe("");
74 expect(getCloneDirectoryName("ssh://git@github.com:22")).toBe("");
75 expect(getCloneDirectoryName("https://")).toBe("");
76 });
77
78 it("proposes the clone destination inside the selected directory", () => {
79 expect(getCloneDestinationPath("~/Projects/", "repo")).toBe("~/Projects/repo");
80 expect(getCloneDestinationPath("~/Projects", "repo")).toBe("~/Projects/repo");
81 expect(getCloneDestinationPath("C:\\work\\", "repo")).toBe("C:\\work\\repo");
82 expect(getCloneDestinationPath("~/Projects/", null)).toBe("~/Projects/");
83 expect(getCloneDestinationPath("~/Projects/", "")).toBe("~/Projects/");
84 });
85
86 it("keeps pinned clone destinations anchored to the browsed directory", () => {
87 expect(
88 getCloneDestinationBrowsePath({
89 browseDirectoryPath: "~/Projects/",
90 selectedDirectoryName: "work",
91 cloneDirectoryName: "repo",
92 caseSensitive: true,
93 }),
94 ).toBe("~/Projects/work/repo");
95 expect(
96 getCloneDestinationBrowsePath({
97 browseDirectoryPath: "~/Projects/",
98 selectedDirectoryName: "repo",
99 cloneDirectoryName: "repo",
100 caseSensitive: true,
101 }),
102 ).toBe("~/Projects/repo/");
103 expect(
104 getCloneDestinationBrowsePath({
105 browseDirectoryPath: "C:\\Projects\\",
106 selectedDirectoryName: "Repo",
107 cloneDirectoryName: "repo",
108 caseSensitive: false,
109 }),
110 ).toBe("C:\\Projects\\Repo\\");
111 });
112
37113 it("rejects unsupported windows paths on non-windows environments", () => {
38114 expect(
39115 resolveAddProjectPath({
packages/client-runtime/src/operations/projects.ts+71
......@@ -13,6 +13,7 @@ import * as Option from "effect/Option";
1313import * as Order from "effect/Order";
1414
1515import {
16 appendBrowsePathSegment,
1617 ensureBrowseDirectoryPath,
1718 findProjectByPath,
1819 inferProjectTitleFromPath,
......@@ -176,6 +177,76 @@ export function getAddProjectInitialQuery(baseDirectory: string | null | undefin
176177 return trimmed.length === 0 ? "~/" : ensureBrowseDirectoryPath(trimmed);
177178}
178179
180/**
181 * Folder name `git clone` would pick, from either a looked-up repository or a
182 * pasted clone URL. Providers report `owner/repo`, Azure DevOps reports
183 * `org/project/repo`, and a URL can arrive in any form: `https://host/owner/
184 * repo.git`, `ssh://git@host:22/owner/repo`, `git@host:owner/repo.git`, with
185 * or without a query, a fragment or a trailing slash. The repository is always
186 * the last segment, minus the `.git` suffix.
187 */
188export function getCloneDirectoryName(repositoryOrRemoteUrl: string | null | undefined): string {
189 const withoutQuery = (repositoryOrRemoteUrl ?? "").split(/[?#]/)[0]?.trim() ?? "";
190 const schemeIndex = withoutQuery.indexOf("://");
191 // A remote URL carries a host before the repository path. The host is never
192 // the repository, so a link that stops at the host, or at a port, names
193 // nothing and the destination falls back to the browsed folder.
194 const hasHost = schemeIndex >= 0 || /^[^/\\:]+@[^/\\:]+:/.test(withoutQuery);
195 const pathPart = schemeIndex >= 0 ? withoutQuery.slice(schemeIndex + "://".length) : withoutQuery;
196 const segments = pathPart.split(/[/\\:]+/).filter((segment) => segment.trim().length > 0);
197 if (hasHost && segments.length < 2) {
198 return "";
199 }
200
201 const lastSegment = segments.at(-1)?.trim() ?? "";
202 // A port can only sit directly behind the authority, so it is a port only
203 // when nothing follows it. Deeper segments are path, even when numeric: the
204 // repository in `https://host/acme/123` really is named `123`.
205 if (hasHost && segments.length === 2 && /^\d+$/.test(lastSegment)) {
206 return "";
207 }
208 return lastSegment.endsWith(".git") ? lastSegment.slice(0, -".git".length) : lastSegment;
209}
210
211/**
212 * Clone destination proposed for a directory: the directory the user picked
213 * plus the repository folder inside it. Without a name the directory is the
214 * destination, which is what the raw clone URL flow keeps doing.
215 */
216export function getCloneDestinationPath(
217 directoryPath: string,
218 directoryName: string | null | undefined,
219): string {
220 const name = directoryName?.trim() ?? "";
221 if (name.length === 0) {
222 return directoryPath;
223 }
224 return `${ensureBrowseDirectoryPath(directoryPath)}${name}`;
225}
226
227/**
228 * Destination query after choosing a directory while the clone folder is
229 * pinned in the path input. Selecting an existing directory with the pinned
230 * name uses that directory directly instead of producing `repo/repo`.
231 */
232export function getCloneDestinationBrowsePath(input: {
233 readonly browseDirectoryPath: string;
234 readonly selectedDirectoryName: string;
235 readonly cloneDirectoryName: string;
236 readonly caseSensitive: boolean;
237}): string {
238 const selectedDirectoryPath = appendBrowsePathSegment(
239 input.browseDirectoryPath,
240 input.selectedDirectoryName,
241 );
242 const selectedDirectoryMatches = input.caseSensitive
243 ? input.selectedDirectoryName === input.cloneDirectoryName
244 : input.selectedDirectoryName.toLowerCase() === input.cloneDirectoryName.toLowerCase();
245 return selectedDirectoryMatches
246 ? selectedDirectoryPath
247 : getCloneDestinationPath(selectedDirectoryPath, input.cloneDirectoryName);
248}
249
179250export function resolveAddProjectPath(input: {
180251 readonly rawPath: string;
181252 readonly currentProjectCwd?: string | null;
packages/client-runtime/src/state/projects.ts+1-1
......@@ -9,7 +9,7 @@ import {
99
1010export { normalizeProjectPathForComparison, normalizeProjectPathForDispatch };
1111
12const isWindowsPlatform = (platform: string): boolean => {
12export const isWindowsPlatform = (platform: string): boolean => {
1313 return /^win(dows)?/i.test(platform);
1414};
1515