| author | |
| committer | |
| log | ec141c125726ae70f31f392e780afa8de446fdc4 |
| tree | 1ccfd2751b0f3b7dc6a7da512ae10097647d4e74 |
| parent | 2fc67623955be6c4e9f439c08ad752386dd7f375 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | }; |
| 10 | 11 | ||
| 11 | export function AddProjectDestinationRoute({ | 12 | export 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"; |
| 24 | import { | 27 | import { |
| 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"; |
| 29 | import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; | 32 | import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 30 | import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; | 33 | import { 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 | } |
| 238 | 241 | ||
| 239 | function 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. | ||
| 245 | function 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 | ); |
| 282 | 314 | ||
| 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]); |
| 289 | 326 | ||
| 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 | ); |
| 724 | 774 | ||
| 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 > 0 | 816 | 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); |
| 851 | 910 | ||
| ... | @@ -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 | |||
| 376 | describe("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"; |
| 6 | import { filterFilesystemBrowseEntries } from "@t3tools/client-runtime/state/filesystem"; | ||
| 6 | import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; | 7 | import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; |
| 7 | import * as Arr from "effect/Array"; | 8 | import * as Arr from "effect/Array"; |
| 8 | import * as Result from "effect/Result"; | 9 | import * 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 | } |
| 399 | 400 | ||
| 401 | export 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 | |||
| 400 | export function getCommandPaletteMode(input: { | 420 | export 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"; |
| 2 | 2 | ||
| 3 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; | 3 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 4 | import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; | 4 | import { |
| 5 | canCreateProjectInEnvironment, | ||
| 6 | getCloneDestinationBrowsePath, | ||
| 7 | getCloneDestinationPath, | ||
| 8 | getCloneDirectoryName, | ||
| 9 | } from "@t3tools/client-runtime/operations/projects"; | ||
| 5 | import { connectionStatusText } from "@t3tools/client-runtime/connection"; | 10 | import { connectionStatusText } from "@t3tools/client-runtime/connection"; |
| 6 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; | 11 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 7 | import { | 12 | import { |
| ... | @@ -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 | ); |
| 885 | 909 | ||
| 886 | const prefetchBrowsePath = useCallback( | 910 | const prefetchBrowsePath = useCallback( |
| ... | @@ -1793,7 +1817,10 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1793,7 +1817,10 @@ function OpenCommandPaletteDialog(props: { |
| 1793 | 1817 | ||
| 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: { |
| 1906 | 1936 | ||
| 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 | ); |
| 1921 | 1965 | ||
| 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 | } |
| 1927 | 1971 | ||
| 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]); |
| 1937 | 1982 | ||
| 1938 | // Resolve the add-project path from browse data when available. When the | 1983 | // Resolve the add-project path from browse data when available. When the |
| 1939 | // query has a trailing separator (e.g. "~/projects/foo/"), parentPath is the | 1984 | // 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 | }); |
| 36 | 39 | ||
| 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"; |
| 13 | import * as Order from "effect/Order"; | 13 | import * as Order from "effect/Order"; |
| 14 | 14 | ||
| 15 | import { | 15 | import { |
| 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 | } |
| 178 | 179 | ||
| 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 | */ | ||
| 188 | export 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 | */ | ||
| 216 | export 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 | */ | ||
| 232 | export 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 | |||
| 179 | export function resolveAddProjectPath(input: { | 250 | export 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 { |
| 9 | 9 | ||
| 10 | export { normalizeProjectPathForComparison, normalizeProjectPathForDispatch }; | 10 | export { normalizeProjectPathForComparison, normalizeProjectPathForDispatch }; |
| 11 | 11 | ||
| 12 | const isWindowsPlatform = (platform: string): boolean => { | 12 | export const isWindowsPlatform = (platform: string): boolean => { |
| 13 | return /^win(dows)?/i.test(platform); | 13 | return /^win(dows)?/i.test(platform); |
| 14 | }; | 14 | }; |
| 15 | 15 |