@shipfox/client-projects 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.storybook/main.ts +1 -0
  2. package/.storybook/preview.css +7 -0
  3. package/.storybook/preview.tsx +56 -0
  4. package/.turbo/turbo-build.log +2 -0
  5. package/.turbo/turbo-type$colon$emit.log +1 -0
  6. package/.turbo/turbo-type.log +1 -0
  7. package/CHANGELOG.md +207 -0
  8. package/LICENSE +21 -0
  9. package/dist/chrome.d.ts +3 -0
  10. package/dist/chrome.d.ts.map +1 -0
  11. package/dist/chrome.js +42 -0
  12. package/dist/chrome.js.map +1 -0
  13. package/dist/components/model-provider-reminder-banner.d.ts +4 -0
  14. package/dist/components/model-provider-reminder-banner.d.ts.map +1 -0
  15. package/dist/components/model-provider-reminder-banner.js +73 -0
  16. package/dist/components/model-provider-reminder-banner.js.map +1 -0
  17. package/dist/components/project-crumb.d.ts +7 -0
  18. package/dist/components/project-crumb.d.ts.map +1 -0
  19. package/dist/components/project-crumb.js +93 -0
  20. package/dist/components/project-crumb.js.map +1 -0
  21. package/dist/components/project-switcher.d.ts +7 -0
  22. package/dist/components/project-switcher.d.ts.map +1 -0
  23. package/dist/components/project-switcher.js +138 -0
  24. package/dist/components/project-switcher.js.map +1 -0
  25. package/dist/components/source-strip.d.ts +14 -0
  26. package/dist/components/source-strip.d.ts.map +1 -0
  27. package/dist/components/source-strip.js +98 -0
  28. package/dist/components/source-strip.js.map +1 -0
  29. package/dist/components/workspace-setup-guard.d.ts +9 -0
  30. package/dist/components/workspace-setup-guard.d.ts.map +1 -0
  31. package/dist/components/workspace-setup-guard.js +160 -0
  32. package/dist/components/workspace-setup-guard.js.map +1 -0
  33. package/dist/feature.d.ts +35 -0
  34. package/dist/feature.d.ts.map +1 -0
  35. package/dist/feature.js +45 -0
  36. package/dist/feature.js.map +1 -0
  37. package/dist/hooks/api/definitions.d.ts +97 -0
  38. package/dist/hooks/api/definitions.d.ts.map +1 -0
  39. package/dist/hooks/api/definitions.js +55 -0
  40. package/dist/hooks/api/definitions.js.map +1 -0
  41. package/dist/hooks/api/projects.d.ts +93 -0
  42. package/dist/hooks/api/projects.d.ts.map +1 -0
  43. package/dist/hooks/api/projects.js +79 -0
  44. package/dist/hooks/api/projects.js.map +1 -0
  45. package/dist/index.d.ts +13 -0
  46. package/dist/index.d.ts.map +1 -0
  47. package/dist/index.js +13 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/pages/create-project-page.d.ts +2 -0
  50. package/dist/pages/create-project-page.d.ts.map +1 -0
  51. package/dist/pages/create-project-page.js +406 -0
  52. package/dist/pages/create-project-page.js.map +1 -0
  53. package/dist/pages/create-project-page.stories.d.ts +24 -0
  54. package/dist/pages/create-project-page.stories.d.ts.map +1 -0
  55. package/dist/pages/create-project-page.stories.js +345 -0
  56. package/dist/pages/create-project-page.stories.js.map +1 -0
  57. package/dist/pages/home-router.d.ts +2 -0
  58. package/dist/pages/home-router.d.ts.map +1 -0
  59. package/dist/pages/home-router.js +7 -0
  60. package/dist/pages/home-router.js.map +1 -0
  61. package/dist/pages/project-workflows-page.d.ts +4 -0
  62. package/dist/pages/project-workflows-page.d.ts.map +1 -0
  63. package/dist/pages/project-workflows-page.js +481 -0
  64. package/dist/pages/project-workflows-page.js.map +1 -0
  65. package/dist/pages/projects-hub-page.d.ts +2 -0
  66. package/dist/pages/projects-hub-page.d.ts.map +1 -0
  67. package/dist/pages/projects-hub-page.js +256 -0
  68. package/dist/pages/projects-hub-page.js.map +1 -0
  69. package/dist/project-error.d.ts +7 -0
  70. package/dist/project-error.d.ts.map +1 -0
  71. package/dist/project-error.js +93 -0
  72. package/dist/project-error.js.map +1 -0
  73. package/dist/routes/create-project.d.ts +6 -0
  74. package/dist/routes/create-project.d.ts.map +1 -0
  75. package/dist/routes/create-project.js +7 -0
  76. package/dist/routes/create-project.js.map +1 -0
  77. package/dist/routes/home.d.ts +6 -0
  78. package/dist/routes/home.d.ts.map +1 -0
  79. package/dist/routes/home.js +7 -0
  80. package/dist/routes/home.js.map +1 -0
  81. package/dist/routes/project-index.d.ts +10 -0
  82. package/dist/routes/project-index.d.ts.map +1 -0
  83. package/dist/routes/project-index.js +15 -0
  84. package/dist/routes/project-index.js.map +1 -0
  85. package/dist/routes/workflows.d.ts +5 -0
  86. package/dist/routes/workflows.d.ts.map +1 -0
  87. package/dist/routes/workflows.js +16 -0
  88. package/dist/routes/workflows.js.map +1 -0
  89. package/dist/tsconfig.test.tsbuildinfo +1 -0
  90. package/package.json +120 -0
  91. package/src/chrome.tsx +26 -0
  92. package/src/components/model-provider-reminder-banner.tsx +78 -0
  93. package/src/components/project-crumb.tsx +70 -0
  94. package/src/components/project-switcher.test.tsx +91 -0
  95. package/src/components/project-switcher.tsx +127 -0
  96. package/src/components/source-strip.tsx +91 -0
  97. package/src/components/workspace-setup-guard.test.tsx +534 -0
  98. package/src/components/workspace-setup-guard.tsx +185 -0
  99. package/src/feature.ts +44 -0
  100. package/src/hooks/api/definitions.ts +50 -0
  101. package/src/hooks/api/projects.test.ts +78 -0
  102. package/src/hooks/api/projects.ts +75 -0
  103. package/src/index.ts +15 -0
  104. package/src/pages/create-project-page.stories.tsx +344 -0
  105. package/src/pages/create-project-page.test.tsx +317 -0
  106. package/src/pages/create-project-page.tsx +386 -0
  107. package/src/pages/home-router.test.tsx +27 -0
  108. package/src/pages/home-router.tsx +5 -0
  109. package/src/pages/project-workflows-page.test.tsx +258 -0
  110. package/src/pages/project-workflows-page.tsx +445 -0
  111. package/src/pages/projects-hub-page.test.tsx +313 -0
  112. package/src/pages/projects-hub-page.tsx +246 -0
  113. package/src/project-error.test.ts +59 -0
  114. package/src/project-error.ts +106 -0
  115. package/src/routes/create-project.tsx +4 -0
  116. package/src/routes/home.tsx +4 -0
  117. package/src/routes/project-index.tsx +11 -0
  118. package/src/routes/workflows.tsx +10 -0
  119. package/test/pages.tsx +151 -0
  120. package/test/setup.ts +3 -0
  121. package/tsconfig.build.json +9 -0
  122. package/tsconfig.build.tsbuildinfo +1 -0
  123. package/tsconfig.json +3 -0
  124. package/tsconfig.test.json +8 -0
  125. package/vitest.config.ts +70 -0
package/package.json ADDED
@@ -0,0 +1,120 @@
1
+ {
2
+ "name": "@shipfox/client-projects",
3
+ "license": "MIT",
4
+ "version": "0.2.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
8
+ "directory": "libs/client/projects"
9
+ },
10
+ "type": "module",
11
+ "main": "dist/index.js",
12
+ "types": "dist/index.d.ts",
13
+ "imports": {
14
+ "#*": {
15
+ "types": "./src/*",
16
+ "workspace-source": "./src/*",
17
+ "development": "./src/*",
18
+ "default": "./dist/*"
19
+ },
20
+ "#test/*": "./test/*"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "development": {
25
+ "types": "./src/index.ts",
26
+ "default": "./src/index.ts"
27
+ },
28
+ "default": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ }
32
+ },
33
+ "./feature": {
34
+ "development": {
35
+ "types": "./src/feature.ts",
36
+ "default": "./src/feature.ts"
37
+ },
38
+ "default": {
39
+ "types": "./dist/feature.d.ts",
40
+ "default": "./dist/feature.js"
41
+ }
42
+ },
43
+ "./routes/*": {
44
+ "development": {
45
+ "types": "./src/routes/*.tsx",
46
+ "default": "./src/routes/*.tsx"
47
+ },
48
+ "default": {
49
+ "types": "./dist/routes/*.d.ts",
50
+ "default": "./dist/routes/*.js"
51
+ }
52
+ }
53
+ },
54
+ "dependencies": {
55
+ "@swc/helpers": "^0.5.17",
56
+ "@tanstack/react-form": "^1.32.0",
57
+ "@shipfox/api-definitions-dto": "2.0.0",
58
+ "@shipfox/api-projects-dto": "2.0.0",
59
+ "@shipfox/api-workflows-dto": "2.0.0",
60
+ "@shipfox/api-integration-core-dto": "3.0.0",
61
+ "@shipfox/client-agent": "0.2.0",
62
+ "@shipfox/client-auth": "0.2.0",
63
+ "@shipfox/client-shell": "0.2.0",
64
+ "@shipfox/client-integrations": "0.2.0",
65
+ "@shipfox/client-api": "0.2.0",
66
+ "@shipfox/client-ui": "0.2.0",
67
+ "@shipfox/client-workflows": "0.2.0",
68
+ "@shipfox/react-ui": "0.3.1"
69
+ },
70
+ "peerDependencies": {
71
+ "@tanstack/react-query": "^5.101.0",
72
+ "@tanstack/react-router": "^1.170.16",
73
+ "jotai": "^2.19.1",
74
+ "react": "^19.0.0",
75
+ "react-dom": "^19.0.0"
76
+ },
77
+ "devDependencies": {
78
+ "@argos-ci/cli": "^5.0.0",
79
+ "@argos-ci/storybook": "^6.0.0",
80
+ "@storybook/addon-vitest": "^10.3.6",
81
+ "@storybook/react": "^10.0.0",
82
+ "@storybook/react-vite": "^10.0.0",
83
+ "@tailwindcss/vite": "^4.1.13",
84
+ "@tanstack/react-query": "^5.101.0",
85
+ "@tanstack/react-router": "^1.170.16",
86
+ "@testing-library/jest-dom": "^6.6.3",
87
+ "@testing-library/react": "^16.3.2",
88
+ "@testing-library/user-event": "^14.6.1",
89
+ "@types/react": "^19.1.11",
90
+ "@types/react-dom": "^19.1.7",
91
+ "@vitejs/plugin-react": "^6.0.0",
92
+ "@vitest/browser-playwright": "^4.1.5",
93
+ "jotai": "^2.19.1",
94
+ "jsdom": "^29.0.0",
95
+ "react": "^19.1.1",
96
+ "react-dom": "^19.1.1",
97
+ "storybook": "^10.0.0",
98
+ "storybook-addon-pseudo-states": "^10.0.0",
99
+ "tailwindcss": "^4.1.13",
100
+ "vite": "^8.0.3",
101
+ "vitest": "^4.1.5",
102
+ "@shipfox/biome": "1.8.1",
103
+ "@shipfox/swc": "1.2.5",
104
+ "@shipfox/client-test-setup": "0.0.2",
105
+ "@shipfox/vitest": "1.2.2",
106
+ "@shipfox/typescript": "1.1.6",
107
+ "@shipfox/ts-config": "1.3.8"
108
+ },
109
+ "scripts": {
110
+ "build": "shipfox-swc",
111
+ "check": "shipfox-biome-check",
112
+ "check:fix": "shipfox-biome-check --write",
113
+ "storybook": "storybook dev -p 6014",
114
+ "storybook:build": "storybook build -o storybook-static",
115
+ "test": "shipfox-vitest-run",
116
+ "test:watch": "shipfox-vitest-watch",
117
+ "type": "shipfox-tsc-check",
118
+ "type:emit": "shipfox-tsc-emit"
119
+ }
120
+ }
package/src/chrome.tsx ADDED
@@ -0,0 +1,26 @@
1
+ import {useActiveWorkspace} from '@shipfox/client-shell/runtime';
2
+ import {Outlet, useNavigate, useParams} from '@tanstack/react-router';
3
+ import {useEffect} from 'react';
4
+ import {ProjectCrumb} from '#components/project-crumb.js';
5
+ import {useProjectQuery} from '#hooks/api/projects.js';
6
+
7
+ export function ProjectBreadcrumb() {
8
+ const workspace = useActiveWorkspace();
9
+ const {pid} = useParams({strict: false}) as {pid?: string};
10
+ const project = useProjectQuery(pid).data;
11
+ return (
12
+ <ProjectCrumb workspaceId={workspace.id} projectId={project?.id} projectName={project?.name} />
13
+ );
14
+ }
15
+
16
+ export function ProjectLayoutGuard() {
17
+ const {wid, pid} = useParams({strict: false}) as {wid: string; pid: string};
18
+ const navigate = useNavigate();
19
+ const project = useProjectQuery(pid).data;
20
+ useEffect(() => {
21
+ if (project && project.workspace_id !== wid)
22
+ void navigate({to: '/workspaces/$wid', params: {wid}, replace: true});
23
+ }, [navigate, project, wid]);
24
+ if (project && project.workspace_id !== wid) return null;
25
+ return <Outlet />;
26
+ }
@@ -0,0 +1,78 @@
1
+ import {useModelProviderConfigsQuery} from '@shipfox/client-agent';
2
+ import {
3
+ Alert,
4
+ AlertActions,
5
+ AlertClose,
6
+ AlertContent,
7
+ AlertDescription,
8
+ AlertTitle,
9
+ } from '@shipfox/react-ui/alert';
10
+ import {Button} from '@shipfox/react-ui/button';
11
+ import {Link} from '@tanstack/react-router';
12
+ import {useState} from 'react';
13
+
14
+ const REMINDER_SESSION_KEY_PREFIX = 'shipfox.modelProviderReminder.dismissed.';
15
+
16
+ export function ModelProviderReminderBanner({workspaceId}: {workspaceId: string}) {
17
+ const configsQuery = useModelProviderConfigsQuery(workspaceId);
18
+ const [dismissed, setDismissed] = useState(() => isReminderDismissed(workspaceId));
19
+ const configs = configsQuery.data?.configs;
20
+ const unconfigured =
21
+ Array.isArray(configs) &&
22
+ configs.length === 0 &&
23
+ configsQuery.data?.default_provider_id === null;
24
+
25
+ if (!unconfigured || dismissed) return null;
26
+
27
+ return (
28
+ <Alert
29
+ variant="info"
30
+ animated={false}
31
+ onOpenChange={(open) => {
32
+ if (!open) {
33
+ dismissReminder(workspaceId);
34
+ setDismissed(true);
35
+ }
36
+ }}
37
+ >
38
+ <AlertContent className="pr-28">
39
+ <AlertTitle>Finish setting up a model provider</AlertTitle>
40
+ <AlertDescription>
41
+ Add workspace credentials, or keep using the instance default.
42
+ </AlertDescription>
43
+ <AlertActions>
44
+ <Button asChild size="sm" variant="secondary">
45
+ <Link to="/workspaces/$wid/settings/agents" params={{wid: workspaceId}}>
46
+ Agents
47
+ </Link>
48
+ </Button>
49
+ </AlertActions>
50
+ </AlertContent>
51
+ <AlertClose />
52
+ </Alert>
53
+ );
54
+ }
55
+
56
+ function isReminderDismissed(workspaceId: string): boolean {
57
+ if (typeof window === 'undefined') return false;
58
+
59
+ try {
60
+ return window.sessionStorage.getItem(sessionKey(workspaceId)) === 'true';
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ function dismissReminder(workspaceId: string): void {
67
+ if (typeof window === 'undefined') return;
68
+
69
+ try {
70
+ window.sessionStorage.setItem(sessionKey(workspaceId), 'true');
71
+ } catch {
72
+ // Session persistence is best-effort; showing the banner again is benign.
73
+ }
74
+ }
75
+
76
+ function sessionKey(workspaceId: string): string {
77
+ return `${REMINDER_SESSION_KEY_PREFIX}${workspaceId}`;
78
+ }
@@ -0,0 +1,70 @@
1
+ import {Icon} from '@shipfox/react-ui/icon';
2
+ import {Popover, PopoverContent, PopoverTrigger} from '@shipfox/react-ui/popover';
3
+ import {Link} from '@tanstack/react-router';
4
+ import {useState} from 'react';
5
+ import {ProjectSwitcher} from './project-switcher.js';
6
+
7
+ export interface ProjectCrumbProps {
8
+ workspaceId: string;
9
+ projectId?: string | undefined;
10
+ projectName?: string | undefined;
11
+ }
12
+
13
+ export function ProjectCrumb({workspaceId, projectId, projectName}: ProjectCrumbProps) {
14
+ const [open, setOpen] = useState(false);
15
+
16
+ if (projectId && projectName) {
17
+ return (
18
+ <div className="flex items-center">
19
+ <Link
20
+ to="/workspaces/$wid/projects/$pid"
21
+ params={{wid: workspaceId, pid: projectId}}
22
+ aria-current="page"
23
+ className="text-md font-medium text-foreground-neutral-base px-6 py-4 rounded-6 hover:bg-background-components-hover transition-colors max-w-[240px] truncate"
24
+ >
25
+ {projectName}
26
+ </Link>
27
+ <Popover open={open} onOpenChange={setOpen}>
28
+ <PopoverTrigger asChild>
29
+ <button
30
+ type="button"
31
+ aria-label="Switch project"
32
+ aria-haspopup="listbox"
33
+ aria-expanded={open}
34
+ className="ml-2 grid place-items-center size-24 rounded-4 text-foreground-neutral-muted hover:bg-background-components-hover hover:text-foreground-neutral-base transition-colors"
35
+ >
36
+ <Icon name="arrowDownSLine" className="size-16" />
37
+ </button>
38
+ </PopoverTrigger>
39
+ <PopoverContent className="w-[320px] p-0" align="start" sideOffset={8}>
40
+ <ProjectSwitcher
41
+ workspaceId={workspaceId}
42
+ activeProjectId={projectId}
43
+ onSelect={() => setOpen(false)}
44
+ />
45
+ </PopoverContent>
46
+ </Popover>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ return (
52
+ <Popover open={open} onOpenChange={setOpen}>
53
+ <PopoverTrigger asChild>
54
+ <button
55
+ type="button"
56
+ aria-label="Switch project"
57
+ aria-haspopup="listbox"
58
+ aria-expanded={open}
59
+ className="flex items-center gap-2 text-md font-medium text-foreground-neutral-base px-6 py-4 rounded-6 hover:bg-background-components-hover transition-colors"
60
+ >
61
+ <span className="max-w-[240px] truncate">All projects</span>
62
+ <Icon name="arrowDownSLine" className="size-16 text-foreground-neutral-muted" />
63
+ </button>
64
+ </PopoverTrigger>
65
+ <PopoverContent className="w-[320px] p-0" align="start" sideOffset={8}>
66
+ <ProjectSwitcher workspaceId={workspaceId} onSelect={() => setOpen(false)} />
67
+ </PopoverContent>
68
+ </Popover>
69
+ );
70
+ }
@@ -0,0 +1,91 @@
1
+ import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
2
+ import {
3
+ createMemoryHistory,
4
+ createRootRoute,
5
+ createRoute,
6
+ createRouter,
7
+ Outlet,
8
+ RouterProvider,
9
+ } from '@tanstack/react-router';
10
+ import {fireEvent, render, screen} from '@testing-library/react';
11
+ import {projectsQueryKeys} from '#hooks/api/projects.js';
12
+ import {PROJECT_TEST_WID} from '#test/pages.js';
13
+ import {ProjectSwitcher} from './project-switcher.js';
14
+
15
+ const CREATE_PROJECT_LABEL_RE = /Create project/u;
16
+
17
+ describe('ProjectSwitcher', () => {
18
+ test('keeps Create project pinned and keyboard-selectable under empty search', async () => {
19
+ const onSelect = vi.fn();
20
+ const projects = [projectDto({id: 'project-1', name: 'Platform'})];
21
+
22
+ renderProjectSwitcher({onSelect, projects});
23
+ await screen.findByRole('option', {name: 'Platform'});
24
+ const createOption = screen.getByRole('option', {name: CREATE_PROJECT_LABEL_RE});
25
+ const searchInput = screen.getByPlaceholderText('Search projects...');
26
+
27
+ expect(createOption.closest('[data-slot="command-list"]')).toBeNull();
28
+
29
+ fireEvent.change(searchInput, {target: {value: 'zzz-no-match'}});
30
+
31
+ expect(screen.getByText('No projects found.')).toBeVisible();
32
+ expect(screen.getByRole('option', {name: CREATE_PROJECT_LABEL_RE})).toBeVisible();
33
+
34
+ searchInput.focus();
35
+ fireEvent.keyDown(searchInput, {key: 'Enter', code: 'Enter'});
36
+
37
+ expect(await screen.findByTestId('create-project-route')).toBeInTheDocument();
38
+ expect(onSelect).toHaveBeenCalledTimes(1);
39
+ });
40
+ });
41
+
42
+ function renderProjectSwitcher({
43
+ onSelect,
44
+ projects,
45
+ }: {
46
+ onSelect: () => void;
47
+ projects: ReturnType<typeof projectDto>[];
48
+ }) {
49
+ const queryClient = new QueryClient({
50
+ defaultOptions: {queries: {retry: false, staleTime: Number.POSITIVE_INFINITY}},
51
+ });
52
+ queryClient.setQueryData(projectsQueryKeys.list(PROJECT_TEST_WID), {
53
+ pages: [{projects, next_cursor: null}],
54
+ pageParams: [undefined],
55
+ });
56
+ const rootRoute = createRootRoute({component: Outlet});
57
+ const workspaceRoute = createRoute({
58
+ getParentRoute: () => rootRoute,
59
+ path: '/workspaces/$wid',
60
+ component: () => <ProjectSwitcher workspaceId={PROJECT_TEST_WID} onSelect={onSelect} />,
61
+ });
62
+ const createProjectRoute = createRoute({
63
+ getParentRoute: () => rootRoute,
64
+ path: '/workspaces/$wid/projects/new',
65
+ component: () => <div data-testid="create-project-route">Create project route</div>,
66
+ });
67
+ const router = createRouter({
68
+ history: createMemoryHistory({initialEntries: [`/workspaces/${PROJECT_TEST_WID}`]}),
69
+ routeTree: rootRoute.addChildren([workspaceRoute, createProjectRoute]),
70
+ });
71
+
72
+ return render(
73
+ <QueryClientProvider client={queryClient}>
74
+ <RouterProvider router={router} />
75
+ </QueryClientProvider>,
76
+ );
77
+ }
78
+
79
+ function projectDto({id, name}: {id: string; name: string}) {
80
+ return {
81
+ id,
82
+ workspace_id: PROJECT_TEST_WID,
83
+ name,
84
+ source: {
85
+ connection_id: '33333333-3333-4333-8333-333333333333',
86
+ external_repository_id: name.toLowerCase(),
87
+ },
88
+ created_at: new Date().toISOString(),
89
+ updated_at: new Date().toISOString(),
90
+ };
91
+ }
@@ -0,0 +1,127 @@
1
+ import {
2
+ Command,
3
+ CommandEmpty,
4
+ CommandGroup,
5
+ CommandInput,
6
+ CommandItem,
7
+ CommandList,
8
+ CommandSeparator,
9
+ } from '@shipfox/react-ui/command';
10
+ import {Icon} from '@shipfox/react-ui/icon';
11
+ import {useNavigate} from '@tanstack/react-router';
12
+ import {type KeyboardEvent, useEffect, useState} from 'react';
13
+ import {useProjectsInfiniteQuery} from '#hooks/api/projects.js';
14
+
15
+ export interface ProjectSwitcherProps {
16
+ workspaceId: string;
17
+ activeProjectId?: string | undefined;
18
+ onSelect?: () => void;
19
+ }
20
+
21
+ export function ProjectSwitcher({workspaceId, activeProjectId, onSelect}: ProjectSwitcherProps) {
22
+ const navigate = useNavigate();
23
+ const [searchValue, setSearchValue] = useState('');
24
+ const query = useProjectsInfiniteQuery(workspaceId);
25
+ const projects = query.data?.pages.flatMap((page) => page.projects) ?? [];
26
+
27
+ // Eagerly fetch additional pages once the popover opens; the switcher should
28
+ // show the full list, not just the first page.
29
+ useEffect(() => {
30
+ if (query.hasNextPage && !query.isFetchingNextPage) {
31
+ query.fetchNextPage();
32
+ }
33
+ }, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]);
34
+
35
+ const handleSelect = (projectId: string) => {
36
+ navigate({
37
+ to: '/workspaces/$wid/projects/$pid',
38
+ params: {wid: workspaceId, pid: projectId},
39
+ });
40
+ onSelect?.();
41
+ };
42
+
43
+ const handleSelectAll = () => {
44
+ navigate({to: '/workspaces/$wid', params: {wid: workspaceId}});
45
+ onSelect?.();
46
+ };
47
+
48
+ const handleCreate = () => {
49
+ navigate({to: '/workspaces/$wid/projects/new', params: {wid: workspaceId}});
50
+ onSelect?.();
51
+ };
52
+
53
+ const normalizedSearch = searchValue.trim().toLowerCase();
54
+ const hasSearchMatch =
55
+ normalizedSearch.length === 0 ||
56
+ 'all projects'.includes(normalizedSearch) ||
57
+ projects.some((project) =>
58
+ [project.id, project.name].some((value) => value.toLowerCase().includes(normalizedSearch)),
59
+ );
60
+ const shouldCreateFromEmptySearch = !query.isLoading && !query.isError && !hasSearchMatch;
61
+
62
+ const handleCommandKeyDown = (event: KeyboardEvent) => {
63
+ if (event.key !== 'Enter' || !shouldCreateFromEmptySearch) {
64
+ return;
65
+ }
66
+
67
+ event.preventDefault();
68
+ handleCreate();
69
+ };
70
+
71
+ return (
72
+ <Command onKeyDown={handleCommandKeyDown}>
73
+ <CommandInput
74
+ placeholder="Search projects..."
75
+ value={searchValue}
76
+ onValueChange={setSearchValue}
77
+ />
78
+ <CommandList>
79
+ {query.isError ? (
80
+ <CommandEmpty>Couldn&apos;t load projects.</CommandEmpty>
81
+ ) : query.isLoading ? (
82
+ <CommandEmpty>Loading projects...</CommandEmpty>
83
+ ) : projects.length === 0 ? (
84
+ <CommandEmpty>No projects yet.</CommandEmpty>
85
+ ) : (
86
+ <CommandEmpty>No projects found.</CommandEmpty>
87
+ )}
88
+ <CommandGroup>
89
+ <CommandItem value="__all" keywords={['all projects']} onSelect={handleSelectAll}>
90
+ <Icon
91
+ name="check"
92
+ className={`size-16 mr-8 ${activeProjectId ? 'opacity-0' : 'opacity-100'}`}
93
+ />
94
+ All projects
95
+ </CommandItem>
96
+ </CommandGroup>
97
+ {projects.length > 0 ? (
98
+ <CommandGroup heading="Projects">
99
+ {projects.map((project) => (
100
+ <CommandItem
101
+ key={project.id}
102
+ value={project.id}
103
+ keywords={[project.name]}
104
+ onSelect={() => handleSelect(project.id)}
105
+ >
106
+ <Icon
107
+ name="check"
108
+ className={`size-16 mr-8 ${
109
+ activeProjectId === project.id ? 'opacity-100' : 'opacity-0'
110
+ }`}
111
+ />
112
+ {project.name}
113
+ </CommandItem>
114
+ ))}
115
+ </CommandGroup>
116
+ ) : null}
117
+ </CommandList>
118
+ <CommandSeparator />
119
+ <CommandGroup forceMount>
120
+ <CommandItem value="__create" onSelect={handleCreate} forceMount>
121
+ <Icon name="addLine" className="size-16" />
122
+ Create project
123
+ </CommandItem>
124
+ </CommandGroup>
125
+ </Command>
126
+ );
127
+ }
@@ -0,0 +1,91 @@
1
+ import type {DefinitionSyncSummaryDto} from '@shipfox/api-definitions-dto';
2
+ import {useActiveWorkspace} from '@shipfox/client-auth';
3
+ import {useSourceConnectionsQuery} from '@shipfox/client-integrations';
4
+ import {StatusBadge} from '@shipfox/react-ui/badge';
5
+ import {Icon, type IconName} from '@shipfox/react-ui/icon';
6
+ import {Skeleton} from '@shipfox/react-ui/skeleton';
7
+ import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
8
+ import {Code, Text} from '@shipfox/react-ui/typography';
9
+
10
+ /**
11
+ * We cannot cheaply resolve `external_repository_id` → `owner/repo`
12
+ * (no by-id endpoint; `listRepositories` is paginated by connection).
13
+ * The strip surfaces the raw id as a `<Code>` chip with a tooltip carrying the
14
+ * full string.
15
+ */
16
+ export function SourceStrip({
17
+ connectionId,
18
+ externalRepositoryId,
19
+ sync,
20
+ isPending,
21
+ }: {
22
+ connectionId: string;
23
+ externalRepositoryId: string;
24
+ sync: DefinitionSyncSummaryDto | null | undefined;
25
+ isPending: boolean;
26
+ }) {
27
+ const workspace = useActiveWorkspace();
28
+ const connectionsQuery = useSourceConnectionsQuery(workspace.id);
29
+ const connection = connectionsQuery.data?.connections.find((c) => c.id === connectionId);
30
+
31
+ return (
32
+ <section
33
+ className="flex flex-col gap-8 rounded-8 border border-border-neutral-base bg-background-neutral-base px-14 py-10 sm:flex-row sm:items-center sm:justify-between"
34
+ aria-label="Project source"
35
+ >
36
+ <div className="flex min-w-0 items-center gap-10">
37
+ <Icon name={providerIconName(connection?.provider)} className="size-20 shrink-0" />
38
+ <div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-10">
39
+ {connectionsQuery.isPending ? (
40
+ <Skeleton className="h-16 w-160" />
41
+ ) : (
42
+ <Text size="sm" bold className="truncate">
43
+ {connection?.display_name ?? 'Connected source'}
44
+ </Text>
45
+ )}
46
+ <Tooltip>
47
+ <TooltipTrigger asChild>
48
+ <Code variant="label" className="truncate text-foreground-neutral-muted">
49
+ {externalRepositoryId}
50
+ </Code>
51
+ </TooltipTrigger>
52
+ <TooltipContent>{externalRepositoryId}</TooltipContent>
53
+ </Tooltip>
54
+ </div>
55
+ </div>
56
+ <div className="shrink-0">
57
+ <SyncBadge sync={sync} isPending={isPending} />
58
+ </div>
59
+ </section>
60
+ );
61
+ }
62
+
63
+ function providerIconName(provider: string | undefined): IconName {
64
+ if (provider === 'github') return 'github' as IconName;
65
+ return 'componentLine' as IconName;
66
+ }
67
+
68
+ function SyncBadge({
69
+ sync,
70
+ isPending,
71
+ }: {
72
+ sync: DefinitionSyncSummaryDto | null | undefined;
73
+ isPending: boolean;
74
+ }) {
75
+ if (isPending) return <StatusBadge variant="neutral">Loading</StatusBadge>;
76
+ if (sync === undefined) return <StatusBadge variant="neutral">Unavailable</StatusBadge>;
77
+ if (sync === null) return <StatusBadge variant="neutral">No sync</StatusBadge>;
78
+
79
+ const variantByStatus = {
80
+ pending: 'neutral',
81
+ syncing: 'info',
82
+ succeeded: 'success',
83
+ failed: 'error',
84
+ } as const;
85
+
86
+ return (
87
+ <StatusBadge variant={variantByStatus[sync.status as keyof typeof variantByStatus]}>
88
+ {sync.status}
89
+ </StatusBadge>
90
+ );
91
+ }