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 = {...@@ -6,6 +6,7 @@ type AddProjectDestinationRouteParams = {
6 readonly source?: string | string[];6 readonly source?: string | string[];
7 readonly remoteUrl?: string | string[];7 readonly remoteUrl?: string | string[];
8 readonly repositoryTitle?: string | string[];8 readonly repositoryTitle?: string | string[];
9 readonly repositoryName?: string | string[];
9};10};
1011
11export function AddProjectDestinationRoute({12export function AddProjectDestinationRoute({
apps/mobile/src/features/projects/AddProjectScreen.tsx+80-20
...@@ -7,6 +7,9 @@ import {...@@ -7,6 +7,9 @@ import {
7 canCreateProjectInEnvironment,7 canCreateProjectInEnvironment,
8 findExistingAddProject,8 findExistingAddProject,
9 getAddProjectInitialQuery,9 getAddProjectInitialQuery,
10 getCloneDestinationBrowsePath,
11 getCloneDestinationPath,
12 getCloneDirectoryName,
10 resolveAddProjectPath,13 resolveAddProjectPath,
11 sortAddProjectProviderSources,14 sortAddProjectProviderSources,
12 type AddProjectRemoteSource,15 type AddProjectRemoteSource,
...@@ -23,8 +26,8 @@ import {...@@ -23,8 +26,8 @@ import {
23} from "@t3tools/client-runtime/state/filesystem";26} from "@t3tools/client-runtime/state/filesystem";
24import {27import {
25 appendBrowsePathSegment,28 appendBrowsePathSegment,
26 ensureBrowseDirectoryPath,
27 inferProjectTitleFromPath,29 inferProjectTitleFromPath,
30 isWindowsPlatform,
28} from "@t3tools/client-runtime/state/projects";31} from "@t3tools/client-runtime/state/projects";
29import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";32import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";
30import { CommonActions, StackActions, useNavigation } from "@react-navigation/native";33import { CommonActions, StackActions, useNavigation } from "@react-navigation/native";
...@@ -236,11 +239,18 @@ function ProjectPathInput(props: {...@@ -236,11 +239,18 @@ function ProjectPathInput(props: {
236 );239 );
237}240}
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 = "") {
240 const environmentId = environment?.environmentId ?? null;246 const environmentId = environment?.environmentId ?? null;
241 const environmentBaseDirectory = environment?.baseDirectory ?? null;247 const environmentBaseDirectory = environment?.baseDirectory ?? null;
248 const clonePathCaseSensitive = !isWindowsPlatform(environment?.platform ?? "");
242 const [pathInput, commitPathInput] = useState(() =>249 const [pathInput, commitPathInput] = useState(() =>
243 getAddProjectInitialQuery(environmentBaseDirectory),250 getCloneDestinationPath(
251 getAddProjectInitialQuery(environmentBaseDirectory),
252 pinnedDirectoryName,
253 ),
244 );254 );
245 const previousEnvironmentIdRef = useRef(environmentId);255 const previousEnvironmentIdRef = useRef(environmentId);
246 const environmentRuntime = useRemoteEnvironmentRuntime(environmentId);256 const environmentRuntime = useRemoteEnvironmentRuntime(environmentId);
...@@ -259,33 +269,60 @@ function useBrowsePathInput(environment: EnvironmentOption | null) {...@@ -259,33 +269,60 @@ function useBrowsePathInput(environment: EnvironmentOption | null) {
259 [browseNavigation],269 [browseNavigation],
260 );270 );
261 const navigateToBrowsePath = useCallback(271 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);
263 setIsBrowseNavigating(true);288 setIsBrowseNavigating(true);
264 const committed = await browseNavigation.run(289 const committed = await browseNavigation.run(
265 async () => {290 async () => {
266 if (environment && canPreloadBrowsePath(environmentRuntime?.connectionState)) {291 if (environment && canPreloadBrowsePath(environmentRuntime?.connectionState)) {
267 await loadBrowsePath({292 await loadBrowsePath({
268 environmentId: environment.environmentId,293 environmentId: environment.environmentId,
269 input: { partialPath: path },294 input: { partialPath: selectedDirectoryPath },
270 });295 });
271 }296 }
272 },297 },
273 () => commitPathInput(path),298 () => commitPathInput(nextPathInput),
274 );299 );
275 if (committed) {300 if (committed) {
276 setIsBrowseNavigating(false);301 setIsBrowseNavigating(false);
277 }302 }
278 return committed;303 return committed;
279 },304 },
280 [browseNavigation, environment, environmentRuntime?.connectionState, loadBrowsePath],305 [
306 browseNavigation,
307 clonePathCaseSensitive,
308 environment,
309 environmentRuntime?.connectionState,
310 loadBrowsePath,
311 pinnedDirectoryName,
312 ],
281 );313 );
282314
283 useEffect(() => {315 useEffect(() => {
284 if (environmentId !== null && environmentId !== previousEnvironmentIdRef.current) {316 if (environmentId !== null && environmentId !== previousEnvironmentIdRef.current) {
285 previousEnvironmentIdRef.current = environmentId;317 previousEnvironmentIdRef.current = environmentId;
286 setPathInput(getAddProjectInitialQuery(environmentBaseDirectory));318 setPathInput(
319 getCloneDestinationPath(
320 getAddProjectInitialQuery(environmentBaseDirectory),
321 pinnedDirectoryName,
322 ),
323 );
287 }324 }
288 }, [environmentBaseDirectory, environmentId, setPathInput]);325 }, [environmentBaseDirectory, environmentId, pinnedDirectoryName, setPathInput]);
289326
290 useEffect(327 useEffect(
291 () => () => {328 () => () => {
...@@ -632,6 +669,7 @@ export function AddProjectRepositoryScreen(props: {...@@ -632,6 +669,7 @@ export function AddProjectRepositoryScreen(props: {
632 source,669 source,
633 remoteUrl,670 remoteUrl,
634 repositoryTitle: remoteUrl,671 repositoryTitle: remoteUrl,
672 repositoryName: getCloneDirectoryName(remoteUrl),
635 }),673 }),
636 );674 );
637 setIsSubmitting(false);675 setIsSubmitting(false);
...@@ -655,6 +693,7 @@ export function AddProjectRepositoryScreen(props: {...@@ -655,6 +693,7 @@ export function AddProjectRepositoryScreen(props: {
655 source,693 source,
656 remoteUrl: repository.sshUrl,694 remoteUrl: repository.sshUrl,
657 repositoryTitle: repository.nameWithOwner,695 repositoryTitle: repository.nameWithOwner,
696 repositoryName: getCloneDirectoryName(repository.nameWithOwner),
658 }),697 }),
659 );698 );
660 }699 }
...@@ -698,7 +737,11 @@ function FolderBrowser(props: {...@@ -698,7 +737,11 @@ function FolderBrowser(props: {
698 readonly environment: EnvironmentOption;737 readonly environment: EnvironmentOption;
699 readonly pathInput: string;738 readonly pathInput: string;
700 readonly setPathInput: (path: string) => void;739 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;
702}) {745}) {
703 const accentColor = useThemeColor("--color-icon-muted");746 const accentColor = useThemeColor("--color-icon-muted");
704 const browsePath = useMemo(747 const browsePath = useMemo(
...@@ -717,9 +760,16 @@ function FolderBrowser(props: {...@@ -717,9 +760,16 @@ function FolderBrowser(props: {
717 input: browseInput,760 input: browseInput,
718 }),761 }),
719 );762 );
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;
720 const { visibleEntries: visibleBrowseEntries } = useMemo(770 const { visibleEntries: visibleBrowseEntries } = useMemo(
721 () => filterFilesystemBrowseEntries(browseState.data?.entries ?? [], browsePath.filterQuery),771 () => filterFilesystemBrowseEntries(browseState.data?.entries ?? [], browseFilterQuery),
722 [browsePath.filterQuery, browseState.data?.entries],772 [browseFilterQuery, browseState.data?.entries],
723 );773 );
724774
725 return (775 return (
...@@ -747,7 +797,9 @@ function FolderBrowser(props: {...@@ -747,7 +797,9 @@ function FolderBrowser(props: {
747 right={null}797 right={null}
748 onPress={() => {798 onPress={() => {
749 if (browsePath.parentPath) {799 if (browsePath.parentPath) {
750 void props.navigateToBrowsePath(browsePath.parentPath);800 void props.navigateToBrowsePath({
801 browseDirectoryPath: browsePath.parentPath,
802 });
751 }803 }
752 }}804 }}
753 />805 />
...@@ -760,11 +812,10 @@ function FolderBrowser(props: {...@@ -760,11 +812,10 @@ function FolderBrowser(props: {
760 isFirst={index === 0 && !browsePath.canBrowseUp}812 isFirst={index === 0 && !browsePath.canBrowseUp}
761 right={null}813 right={null}
762 onPress={() => {814 onPress={() => {
763 const nextPath =815 void props.navigateToBrowsePath({
764 browsePath.directoryPath.length > 0816 browseDirectoryPath: browsePath.directoryPath,
765 ? appendBrowsePathSegment(browsePath.directoryPath, entry.name)817 selectedDirectoryName: entry.name,
766 : ensureBrowseDirectoryPath(entry.fullPath);818 });
767 void props.navigateToBrowsePath(nextPath);
768 }}819 }}
769 />820 />
770 ))}821 ))}
...@@ -836,6 +887,7 @@ export function AddProjectDestinationScreen(props: {...@@ -836,6 +887,7 @@ export function AddProjectDestinationScreen(props: {
836 readonly environmentId?: string | string[];887 readonly environmentId?: string | string[];
837 readonly remoteUrl?: string | string[];888 readonly remoteUrl?: string | string[];
838 readonly repositoryTitle?: string | string[];889 readonly repositoryTitle?: string | string[];
890 readonly repositoryName?: string | string[];
839}) {891}) {
840 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {892 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {
841 reportFailure: false,893 reportFailure: false,
...@@ -844,8 +896,15 @@ export function AddProjectDestinationScreen(props: {...@@ -844,8 +896,15 @@ export function AddProjectDestinationScreen(props: {
844 const createProject = useCreateProject(environment);896 const createProject = useCreateProject(environment);
845 const remoteUrl = stringParam(props.remoteUrl);897 const remoteUrl = stringParam(props.remoteUrl);
846 const repositoryTitle = stringParam(props.repositoryTitle);898 const repositoryTitle = stringParam(props.repositoryTitle);
847 const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } =899 // A lookup derives this from "owner/repo", a pasted clone URL from its own
848 useBrowsePathInput(environment);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 );
849 const [isSubmitting, setIsSubmitting] = useState(false);908 const [isSubmitting, setIsSubmitting] = useState(false);
850 const [error, setError] = useState<string | null>(null);909 const [error, setError] = useState<string | null>(null);
851910
...@@ -918,6 +977,7 @@ export function AddProjectDestinationScreen(props: {...@@ -918,6 +977,7 @@ export function AddProjectDestinationScreen(props: {
918 navigateToBrowsePath={navigateToBrowsePath}977 navigateToBrowsePath={navigateToBrowsePath}
919 pathInput={pathInput}978 pathInput={pathInput}
920 setPathInput={setPathInput}979 setPathInput={setPathInput}
980 pinnedDirectoryName={repositoryName}
921 />981 />
922 </>982 </>
923 ) : (983 ) : (
apps/web/src/components/CommandPalette.logic.test.ts+37
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 buildBrowseGroups,6 buildBrowseGroups,
7 buildThreadActionItems,7 buildThreadActionItems,
8 enumerateCommandPaletteItems,8 enumerateCommandPaletteItems,
9 filterPinnedBrowseEntries,
9 filterCommandPaletteGroups,10 filterCommandPaletteGroups,
10 reduceCommandPaletteUiState,11 reduceCommandPaletteUiState,
11 type CommandPaletteGroup,12 type CommandPaletteGroup,
...@@ -371,3 +372,39 @@ describe("buildBrowseGroups", () => {...@@ -371,3 +372,39 @@ describe("buildBrowseGroups", () => {
371 expect(actionSettled).toBe(true);372 expect(actionSettled).toBe(true);
372 });373 });
373});374});
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 {...@@ -3,6 +3,7 @@ import {
3 type KeybindingCommand,3 type KeybindingCommand,
4 THREAD_JUMP_KEYBINDING_COMMANDS,4 THREAD_JUMP_KEYBINDING_COMMANDS,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { filterFilesystemBrowseEntries } from "@t3tools/client-runtime/state/filesystem";
6import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";7import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";
7import * as Arr from "effect/Array";8import * as Arr from "effect/Array";
8import * as Result from "effect/Result";9import * as Result from "effect/Result";
...@@ -397,6 +398,25 @@ export function buildBrowseGroups(input: {...@@ -397,6 +398,25 @@ export function buildBrowseGroups(input: {
397 return [{ value: "directories", label: "Directories", items }];398 return [{ value: "directories", label: "Directories", items }];
398}399}
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
400export function getCommandPaletteMode(input: {420export function getCommandPaletteMode(input: {
401 currentView: CommandPaletteView | null;421 currentView: CommandPaletteView | null;
402 isBrowsing: boolean;422 isBrowsing: boolean;
apps/web/src/components/CommandPalette.tsx+54-9
...@@ -1,7 +1,12 @@...@@ -1,7 +1,12 @@
1"use client";1"use client";
22
3import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";3import { 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";
5import { connectionStatusText } from "@t3tools/client-runtime/connection";10import { connectionStatusText } from "@t3tools/client-runtime/connection";
6import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";11import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
7import {12import {
...@@ -109,6 +114,7 @@ import {...@@ -109,6 +114,7 @@ import {
109 type CommandPaletteSubmenuItem,114 type CommandPaletteSubmenuItem,
110 type CommandPaletteView,115 type CommandPaletteView,
111 filterCommandPaletteGroups,116 filterCommandPaletteGroups,
117 filterPinnedBrowseEntries,
112 getCommandPaletteInputPlaceholder,118 getCommandPaletteInputPlaceholder,
113 getCommandPaletteMode,119 getCommandPaletteMode,
114 ITEM_ICON_CLASS,120 ITEM_ICON_CLASS,
...@@ -807,6 +813,16 @@ function OpenCommandPaletteDialog(props: {...@@ -807,6 +813,16 @@ function OpenCommandPaletteDialog(props: {
807 );813 );
808 const isRemoteProjectCloneFlow = addProjectCloneFlow !== null;814 const isRemoteProjectCloneFlow = addProjectCloneFlow !== null;
809 const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository";815 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 : "";
810 const browsePath = useMemo(826 const browsePath = useMemo(
811 () => getFilesystemBrowsePath(query, browseEnvironmentPlatform, !isRemoteProjectRepositoryStep),827 () => getFilesystemBrowsePath(query, browseEnvironmentPlatform, !isRemoteProjectRepositoryStep),
812 [browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query],828 [browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query],
...@@ -879,8 +895,16 @@ function OpenCommandPaletteDialog(props: {...@@ -879,8 +895,16 @@ function OpenCommandPaletteDialog(props: {
879 const isBrowsePending = browseQuery.isPending;895 const isBrowsePending = browseQuery.isPending;
880 const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES;896 const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES;
881 const { visibleEntries: visibleBrowseEntries, exactEntry: exactBrowseEntry } = useMemo(897 const { visibleEntries: visibleBrowseEntries, exactEntry: exactBrowseEntry } = useMemo(
882 () => filterFilesystemBrowseEntries(browseEntries, browsePath.filterQuery),898 () =>
883 [browseEntries, browsePath.filterQuery],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],
884 );908 );
885909
886 const prefetchBrowsePath = useCallback(910 const prefetchBrowsePath = useCallback(
...@@ -1793,7 +1817,10 @@ function OpenCommandPaletteDialog(props: {...@@ -1793,7 +1817,10 @@ function OpenCommandPaletteDialog(props: {
17931817
1794 const provider = remoteProjectSourceProvider(addProjectCloneFlow.source);1818 const provider = remoteProjectSourceProvider(addProjectCloneFlow.source);
1795 if (!provider) {1819 if (!provider) {
1796 const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId);1820 const destinationPath = getCloneDestinationPath(
1821 getDefaultCloneParentPath(addProjectCloneFlow.environmentId),
1822 getCloneDirectoryName(rawRepository),
1823 );
1797 setAddProjectCloneFlow({1824 setAddProjectCloneFlow({
1798 step: "confirm",1825 step: "confirm",
1799 environmentId: addProjectCloneFlow.environmentId,1826 environmentId: addProjectCloneFlow.environmentId,
...@@ -1830,7 +1857,10 @@ function OpenCommandPaletteDialog(props: {...@@ -1830,7 +1857,10 @@ function OpenCommandPaletteDialog(props: {
1830 return;1857 return;
1831 }1858 }
1832 const repository = lookupResult.value;1859 const repository = lookupResult.value;
1833 const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId);1860 const destinationPath = getCloneDestinationPath(
1861 getDefaultCloneParentPath(addProjectCloneFlow.environmentId),
1862 getCloneDirectoryName(repository.nameWithOwner),
1863 );
1834 setAddProjectCloneFlow({1864 setAddProjectCloneFlow({
1835 step: "confirm",1865 step: "confirm",
1836 environmentId: addProjectCloneFlow.environmentId,1866 environmentId: addProjectCloneFlow.environmentId,
...@@ -1906,7 +1936,14 @@ function OpenCommandPaletteDialog(props: {...@@ -1906,7 +1936,14 @@ function OpenCommandPaletteDialog(props: {
19061936
1907 const browseTo = useCallback(1937 const browseTo = useCallback(
1908 async (name: string): Promise<void> => {1938 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);
1910 await browseNavigation.run(1947 await browseNavigation.run(
1911 () => prefetchBrowsePath(getBrowseDirectoryPath(nextQuery)),1948 () => prefetchBrowsePath(getBrowseDirectoryPath(nextQuery)),
1912 () => {1949 () => {
...@@ -1916,7 +1953,14 @@ function OpenCommandPaletteDialog(props: {...@@ -1916,7 +1953,14 @@ function OpenCommandPaletteDialog(props: {
1916 },1953 },
1917 );1954 );
1918 },1955 },
1919 [browseNavigation, prefetchBrowsePath, query],1956 [
1957 browseNavigation,
1958 browseEnvironmentPlatform,
1959 browsePath.directoryPath,
1960 pinnedCloneDirectoryName,
1961 prefetchBrowsePath,
1962 query,
1963 ],
1920 );1964 );
19211965
1922 const browseUp = useCallback(async (): Promise<void> => {1966 const browseUp = useCallback(async (): Promise<void> => {
...@@ -1925,15 +1969,16 @@ function OpenCommandPaletteDialog(props: {...@@ -1925,15 +1969,16 @@ function OpenCommandPaletteDialog(props: {
1925 return;1969 return;
1926 }1970 }
19271971
1972 const nextQuery = getCloneDestinationPath(parentPath, pinnedCloneDirectoryName);
1928 await browseNavigation.run(1973 await browseNavigation.run(
1929 () => prefetchBrowsePath(parentPath),1974 () => prefetchBrowsePath(parentPath),
1930 () => {1975 () => {
1931 setHighlightedItemValue(null);1976 setHighlightedItemValue(null);
1932 setQuery(parentPath);1977 setQuery(nextQuery);
1933 setBrowseGeneration((generation) => generation + 1);1978 setBrowseGeneration((generation) => generation + 1);
1934 },1979 },
1935 );1980 );
1936 }, [browseNavigation, browsePath.parentPath, prefetchBrowsePath]);1981 }, [browseNavigation, browsePath.parentPath, pinnedCloneDirectoryName, prefetchBrowsePath]);
19371982
1938 // Resolve the add-project path from browse data when available. When the1983 // Resolve the add-project path from browse data when available. When the
1939 // query has a trailing separator (e.g. "~/projects/foo/"), parentPath is the1984 // 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 {...@@ -13,6 +13,9 @@ import {
13 canCreateProjectInEnvironment,13 canCreateProjectInEnvironment,
14 findExistingAddProject,14 findExistingAddProject,
15 getAddProjectInitialQuery,15 getAddProjectInitialQuery,
16 getCloneDestinationBrowsePath,
17 getCloneDestinationPath,
18 getCloneDirectoryName,
16 resolveAddProjectPath,19 resolveAddProjectPath,
17 sortAddProjectProviderSources,20 sortAddProjectProviderSources,
18} from "./projects.ts";21} from "./projects.ts";
...@@ -34,6 +37,79 @@ describe("add project shared logic", () => {...@@ -34,6 +37,79 @@ describe("add project shared logic", () => {
34 expect(getAddProjectInitialQuery("C:\\work")).toBe("C:\\work\\");37 expect(getAddProjectInitialQuery("C:\\work")).toBe("C:\\work\\");
35 });38 });
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
37 it("rejects unsupported windows paths on non-windows environments", () => {113 it("rejects unsupported windows paths on non-windows environments", () => {
38 expect(114 expect(
39 resolveAddProjectPath({115 resolveAddProjectPath({
packages/client-runtime/src/operations/projects.ts+71
...@@ -13,6 +13,7 @@ import * as Option from "effect/Option";...@@ -13,6 +13,7 @@ import * as Option from "effect/Option";
13import * as Order from "effect/Order";13import * as Order from "effect/Order";
1414
15import {15import {
16 appendBrowsePathSegment,
16 ensureBrowseDirectoryPath,17 ensureBrowseDirectoryPath,
17 findProjectByPath,18 findProjectByPath,
18 inferProjectTitleFromPath,19 inferProjectTitleFromPath,
...@@ -176,6 +177,76 @@ export function getAddProjectInitialQuery(baseDirectory: string | null | undefin...@@ -176,6 +177,76 @@ export function getAddProjectInitialQuery(baseDirectory: string | null | undefin
176 return trimmed.length === 0 ? "~/" : ensureBrowseDirectoryPath(trimmed);177 return trimmed.length === 0 ? "~/" : ensureBrowseDirectoryPath(trimmed);
177}178}
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
179export function resolveAddProjectPath(input: {250export function resolveAddProjectPath(input: {
180 readonly rawPath: string;251 readonly rawPath: string;
181 readonly currentProjectCwd?: string | null;252 readonly currentProjectCwd?: string | null;
packages/client-runtime/src/state/projects.ts+1-1
...@@ -9,7 +9,7 @@ import {...@@ -9,7 +9,7 @@ import {
99
10export { normalizeProjectPathForComparison, normalizeProjectPathForDispatch };10export { normalizeProjectPathForComparison, normalizeProjectPathForDispatch };
1111
12const isWindowsPlatform = (platform: string): boolean => {12export const isWindowsPlatform = (platform: string): boolean => {
13 return /^win(dows)?/i.test(platform);13 return /^win(dows)?/i.test(platform);
14};14};
1515