diff --git a/apps/desktop/src/lib/ai/butlerClient.ts b/apps/desktop/src/lib/ai/butlerClient.ts index 8dbe79560d..95b79af5ad 100644 --- a/apps/desktop/src/lib/ai/butlerClient.ts +++ b/apps/desktop/src/lib/ai/butlerClient.ts @@ -7,7 +7,7 @@ import { import { ModelKind, type AIClient, type AIEvalOptions, type Prompt } from '$lib/ai/types'; import { andThenAsync, ok, wrapAsync, type Result } from '$lib/result'; import { stringStreamGenerator } from '$lib/utils/promise'; -import type { HttpClient } from '@gitbutler/shared/httpClient'; +import type { HttpClient } from '@gitbutler/shared/network/httpClient'; function splitPromptMessagesIfNecessary( modelKind: ModelKind, diff --git a/apps/desktop/src/lib/ai/service.test.ts b/apps/desktop/src/lib/ai/service.test.ts index 870b66f6e7..a37f494337 100644 --- a/apps/desktop/src/lib/ai/service.test.ts +++ b/apps/desktop/src/lib/ai/service.test.ts @@ -24,7 +24,7 @@ import { import { buildFailureFromAny, ok, unwrap, type Result } from '$lib/result'; import { TokenMemoryService } from '$lib/stores/tokenMemoryService'; import { Hunk } from '$lib/vbranches/types'; -import { HttpClient } from '@gitbutler/shared/httpClient'; +import { HttpClient } from '@gitbutler/shared/network/httpClient'; import { plainToInstance } from 'class-transformer'; import { get } from 'svelte/store'; import { expect, test, describe, vi } from 'vitest'; diff --git a/apps/desktop/src/lib/ai/service.ts b/apps/desktop/src/lib/ai/service.ts index fd5c46c776..d32c092381 100644 --- a/apps/desktop/src/lib/ai/service.ts +++ b/apps/desktop/src/lib/ai/service.ts @@ -21,7 +21,7 @@ import { get } from 'svelte/store'; import type { GitConfigService } from '$lib/backend/gitConfigService'; import type { SecretsService } from '$lib/secrets/secretsService'; import type { TokenMemoryService } from '$lib/stores/tokenMemoryService'; -import type { HttpClient } from '@gitbutler/shared/httpClient'; +import type { HttpClient } from '@gitbutler/shared/network/httpClient'; const maxDiffLengthLimitForAPI = 5000; const prDescriptionTokenLimit = 4096; diff --git a/apps/desktop/src/lib/backend/projectCloudSync.svelte.ts b/apps/desktop/src/lib/backend/projectCloudSync.svelte.ts index 439a9493b7..4513aecc92 100644 --- a/apps/desktop/src/lib/backend/projectCloudSync.svelte.ts +++ b/apps/desktop/src/lib/backend/projectCloudSync.svelte.ts @@ -2,7 +2,7 @@ import { registerInterest } from '@gitbutler/shared/interest/registerInterestFun import { projectsSelectors } from '@gitbutler/shared/organizations/projectsSlice'; import { readableToReactive } from '@gitbutler/shared/reactiveUtils.svelte'; import type { ProjectService, ProjectsService } from '$lib/backend/projects'; -import type { HttpClient } from '@gitbutler/shared/httpClient'; +import type { HttpClient } from '@gitbutler/shared/network/httpClient'; import type { ProjectService as CloudProjectService } from '@gitbutler/shared/organizations/projectService'; import type { AppProjectsState } from '@gitbutler/shared/redux/store.svelte'; diff --git a/apps/desktop/src/lib/backend/projects.ts b/apps/desktop/src/lib/backend/projects.ts index 29669657d9..b11ffbb5b6 100644 --- a/apps/desktop/src/lib/backend/projects.ts +++ b/apps/desktop/src/lib/backend/projects.ts @@ -6,7 +6,7 @@ import { persisted } from '@gitbutler/shared/persisted'; import { open } from '@tauri-apps/plugin-dialog'; import { plainToInstance } from 'class-transformer'; import { derived, get, writable, type Readable } from 'svelte/store'; -import type { HttpClient } from '@gitbutler/shared/httpClient'; +import type { HttpClient } from '@gitbutler/shared/network/httpClient'; import { goto } from '$app/navigation'; export type KeyType = 'gitCredentialsHelper' | 'local' | 'systemExecutable'; diff --git a/apps/desktop/src/lib/components/ShareIssueModal.svelte b/apps/desktop/src/lib/components/ShareIssueModal.svelte index ec9659ff2e..4b3ecf2384 100644 --- a/apps/desktop/src/lib/components/ShareIssueModal.svelte +++ b/apps/desktop/src/lib/components/ShareIssueModal.svelte @@ -4,7 +4,7 @@ import { User } from '$lib/stores/user'; import * as toasts from '$lib/utils/toasts'; import { getContext, getContextStore } from '@gitbutler/shared/context'; - import { HttpClient } from '@gitbutler/shared/httpClient'; + import { HttpClient } from '@gitbutler/shared/network/httpClient'; import Button from '@gitbutler/ui/Button.svelte'; import Checkbox from '@gitbutler/ui/Checkbox.svelte'; import Modal from '@gitbutler/ui/Modal.svelte'; diff --git a/apps/desktop/src/lib/stores/user.ts b/apps/desktop/src/lib/stores/user.ts index 6eaa6b9cad..83fb8f6527 100644 --- a/apps/desktop/src/lib/stores/user.ts +++ b/apps/desktop/src/lib/stores/user.ts @@ -4,7 +4,7 @@ import { showError } from '$lib/notifications/toasts'; import { copyToClipboard } from '$lib/utils/clipboard'; import { sleep } from '$lib/utils/sleep'; import { openExternalUrl } from '$lib/utils/url'; -import { type HttpClient } from '@gitbutler/shared/httpClient'; +import { type HttpClient } from '@gitbutler/shared/network/httpClient'; import { plainToInstance } from 'class-transformer'; import { derived, writable } from 'svelte/store'; import type { PostHogWrapper } from '$lib/analytics/posthog'; diff --git a/apps/desktop/src/routes/+layout.svelte b/apps/desktop/src/routes/+layout.svelte index 6c2548ffc7..db39bfca8a 100644 --- a/apps/desktop/src/routes/+layout.svelte +++ b/apps/desktop/src/routes/+layout.svelte @@ -39,7 +39,7 @@ import * as events from '$lib/utils/events'; import { unsubscribe } from '$lib/utils/unsubscribe'; import { FeedService } from '@gitbutler/shared/feeds/service'; - import { HttpClient } from '@gitbutler/shared/httpClient'; + import { HttpClient } from '@gitbutler/shared/network/httpClient'; import { OrganizationService } from '@gitbutler/shared/organizations/organizationService'; import { ProjectService as CloudProjectService } from '@gitbutler/shared/organizations/projectService'; import { AppDispatch, AppState } from '@gitbutler/shared/redux/store.svelte'; diff --git a/apps/desktop/src/routes/+layout.ts b/apps/desktop/src/routes/+layout.ts index 50ac87faeb..c279613451 100644 --- a/apps/desktop/src/routes/+layout.ts +++ b/apps/desktop/src/routes/+layout.ts @@ -14,7 +14,7 @@ import { RemotesService } from '$lib/remotes/service'; import { RustSecretService } from '$lib/secrets/secretsService'; import { TokenMemoryService } from '$lib/stores/tokenMemoryService'; import { UserService } from '$lib/stores/user'; -import { HttpClient } from '@gitbutler/shared/httpClient'; +import { HttpClient } from '@gitbutler/shared/network/httpClient'; import { LineManagerFactory } from '@gitbutler/ui/commitLines/lineManager'; import { LineManagerFactory as StackingLineManagerFactory } from '@gitbutler/ui/commitLines/lineManager'; import lscache from 'lscache'; diff --git a/apps/desktop/src/routes/[projectId]/+layout.svelte b/apps/desktop/src/routes/[projectId]/+layout.svelte index c1d3477822..182c3430ae 100644 --- a/apps/desktop/src/routes/[projectId]/+layout.svelte +++ b/apps/desktop/src/routes/[projectId]/+layout.svelte @@ -36,7 +36,7 @@ import { VirtualBranchService } from '$lib/vbranches/virtualBranch'; import { CloudBranchesService } from '@gitbutler/shared/cloud/stacks/service'; import { getContext } from '@gitbutler/shared/context'; - import { HttpClient } from '@gitbutler/shared/httpClient'; + import { HttpClient } from '@gitbutler/shared/network/httpClient'; import { ProjectService as CloudProjectService } from '@gitbutler/shared/organizations/projectService'; import { AppState } from '@gitbutler/shared/redux/store.svelte'; import { DesktopRoutesService, getRoutesService } from '@gitbutler/shared/sharedRoutes'; diff --git a/apps/desktop/src/routes/settings/organizations/+page.svelte b/apps/desktop/src/routes/settings/organizations/+page.svelte index 332ecf0de4..4ce7cbb43e 100644 --- a/apps/desktop/src/routes/settings/organizations/+page.svelte +++ b/apps/desktop/src/routes/settings/organizations/+page.svelte @@ -1,8 +1,9 @@ + +{#if loadable?.type === 'found'} + {@render children(loadable.value)} +{:else if loadable?.type === 'loading'} +

Loading...

+{:else if loadable?.type === 'not-found'} +

Not found

+{:else if loadable?.type === 'error'} +

{loadable.error.message}

+{:else} +

Organization uninitialized

+{/if} diff --git a/packages/shared/src/lib/httpClient.ts b/packages/shared/src/lib/network/httpClient.ts similarity index 95% rename from packages/shared/src/lib/httpClient.ts rename to packages/shared/src/lib/network/httpClient.ts index 60ea55af6d..60803e11a2 100644 --- a/packages/shared/src/lib/httpClient.ts +++ b/packages/shared/src/lib/network/httpClient.ts @@ -1,3 +1,4 @@ +import { ApiError } from '$lib/network/types'; import { derived, get, type Readable } from 'svelte/store'; export const DEFAULT_HEADERS = { @@ -100,7 +101,7 @@ async function parseResponseJSON(response: Response) { if (response.status === 204 || response.status === 205) { return null; } else if (response.status >= 400) { - throw new Error(`HTTP Error ${response.statusText}: ${await response.text()}`); + throw new ApiError(`HTTP Error ${response.statusText}: ${await response.text()}`, response); } else { return await response.json(); } diff --git a/packages/shared/src/lib/network/loadable.ts b/packages/shared/src/lib/network/loadable.ts new file mode 100644 index 0000000000..66f93f6e87 --- /dev/null +++ b/packages/shared/src/lib/network/loadable.ts @@ -0,0 +1,4 @@ +export type LoadableData = + | { type: 'loading' | 'not-found'; id: Id; error?: undefined } + | { type: 'found'; id: Id; value: T; error?: undefined } + | { type: 'error'; id: Id; error: Error }; diff --git a/packages/shared/src/lib/network/types.ts b/packages/shared/src/lib/network/types.ts new file mode 100644 index 0000000000..d853489eb9 --- /dev/null +++ b/packages/shared/src/lib/network/types.ts @@ -0,0 +1,8 @@ +export class ApiError extends Error { + constructor( + message: string, + readonly response: Response + ) { + super(message); + } +} diff --git a/packages/shared/src/lib/organizations/OrganizationModal.svelte b/packages/shared/src/lib/organizations/OrganizationModal.svelte index 8a8f953a08..11042a7fc1 100644 --- a/packages/shared/src/lib/organizations/OrganizationModal.svelte +++ b/packages/shared/src/lib/organizations/OrganizationModal.svelte @@ -1,6 +1,7 @@ - - - -
Users:
- {#if organization?.inviteCode} -
-

Invite code:

- -
- {/if} - -
- {#each users as user, index} - - - - -

{user.user?.name}

-
- {/each} -
- -
Projects:
-
- {#each projects as { project, projectInterest }, index} - - - -

{project?.name}

-
- {/each} -
+ + + {#snippet children(organization)} + + +
Users:
+ {#if organization.inviteCode} +
+

Invite code:

+ +
+ {/if} + +
+ {#each users as { user, interest }, index} + + + + +

{user?.name}

+
+ {/each} +
+ +
Projects:
+
+ {#each projects as { project, interest }, index} + + + +

{project?.name}

+
+ {/each} +
+ {/snippet} +
diff --git a/packages/shared/src/lib/organizations/organizationService.ts b/packages/shared/src/lib/organizations/organizationService.ts index 877f8bde3d..3c93be7add 100644 --- a/packages/shared/src/lib/organizations/organizationService.ts +++ b/packages/shared/src/lib/organizations/organizationService.ts @@ -1,15 +1,21 @@ import { InterestStore, type Interest } from '$lib/interest/intrestStore'; -import { upsertOrganization, upsertOrganizations } from '$lib/organizations/organizationsSlice'; +import { type HttpClient } from '$lib/network/httpClient'; +import { ApiError } from '$lib/network/types'; +import { + addOrganization, + upsertOrganization, + upsertOrganizations +} from '$lib/organizations/organizationsSlice'; import { upsertProjects } from '$lib/organizations/projectsSlice'; import { apiToOrganization, apiToProject, type ApiOrganization, type ApiOrganizationWithDetails, + type LoadableOrganization, type Organization } from '$lib/organizations/types'; import { POLLING_REGULAR, POLLING_SLOW } from '$lib/polling'; -import type { HttpClient } from '$lib/httpClient'; import type { AppDispatch } from '$lib/redux/store.svelte'; export class OrganizationService { @@ -25,7 +31,14 @@ export class OrganizationService { return this.organizationListingInterests .findOrCreateSubscribable(undefined, async () => { const apiOrganizations = await this.httpClient.get('organization'); - const organizations = apiOrganizations.map(apiToOrganization); + const organizations = apiOrganizations.map( + (apiOrganizations) => + ({ + type: 'found', + id: apiOrganizations.slug, + value: apiToOrganization(apiOrganizations) + }) as LoadableOrganization + ); this.appDispatch.dispatch(upsertOrganizations(organizations)); }) @@ -35,14 +48,26 @@ export class OrganizationService { getOrganizationWithDetailsInterest(slug: string): Interest { return this.orgnaizationInterests .findOrCreateSubscribable({ slug }, async () => { - const apiOrganization = await this.httpClient.get( - `organization/${slug}` - ); - const organization = apiToOrganization(apiOrganization); - const projects = apiOrganization.projects.map(apiToProject); + this.appDispatch.dispatch(addOrganization({ type: 'loading', id: slug })); + + try { + const apiOrganization = await this.httpClient.get( + `organization/${slug}` + ); + const organization = apiToOrganization(apiOrganization); + const projects = apiOrganization.projects.map(apiToProject); - this.appDispatch.dispatch(upsertOrganization(organization)); - this.appDispatch.dispatch(upsertProjects(projects)); + this.appDispatch.dispatch( + upsertOrganization({ type: 'found', id: slug, value: organization }) + ); + this.appDispatch.dispatch(upsertProjects(projects)); + } catch (error: unknown) { + if (error instanceof ApiError && error.response.status === 404) { + this.appDispatch.dispatch(upsertOrganization({ type: 'not-found', id: slug })); + } else if (error instanceof Error) { + this.appDispatch.dispatch(upsertOrganization({ type: 'error', id: slug, error })); + } + } }) .createInterest(); } @@ -59,10 +84,10 @@ export class OrganizationService { description } }); - const orgnaization = apiToOrganization(apiOrganization); - this.appDispatch.dispatch(upsertOrganization(orgnaization)); + const organization = apiToOrganization(apiOrganization); + this.appDispatch.dispatch(upsertOrganization({ type: 'found', id: slug, value: organization })); - return orgnaization; + return organization; } async joinOrganization(slug: string, joinCode: string) { @@ -73,9 +98,9 @@ export class OrganizationService { } ); - const orgnaization = apiToOrganization(apiOrganization); - this.appDispatch.dispatch(upsertOrganization(orgnaization)); + const organization = apiToOrganization(apiOrganization); + this.appDispatch.dispatch(upsertOrganization({ type: 'found', id: slug, value: organization })); - return orgnaization; + return organization; } } diff --git a/packages/shared/src/lib/organizations/organizationsSlice.ts b/packages/shared/src/lib/organizations/organizationsSlice.ts index 87883f2235..3b09212363 100644 --- a/packages/shared/src/lib/organizations/organizationsSlice.ts +++ b/packages/shared/src/lib/organizations/organizationsSlice.ts @@ -1,9 +1,8 @@ import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; -import type { Organization } from '$lib/organizations/types'; +import type { LoadableOrganization } from '$lib/organizations/types'; -const organizationsAdapter = createEntityAdapter({ - selectId: (organization: Organization) => organization.slug, - sortComparer: (a: Organization, b: Organization) => a.slug.localeCompare(b.slug) +const organizationsAdapter = createEntityAdapter({ + selectId: (organization: LoadableOrganization) => organization.id }); const organizationsSlice = createSlice({ diff --git a/packages/shared/src/lib/organizations/projectService.ts b/packages/shared/src/lib/organizations/projectService.ts index d6913ee296..109c031501 100644 --- a/packages/shared/src/lib/organizations/projectService.ts +++ b/packages/shared/src/lib/organizations/projectService.ts @@ -2,7 +2,7 @@ import { InterestStore, type Interest } from '$lib/interest/intrestStore'; import { upsertProject } from '$lib/organizations/projectsSlice'; import { type ApiProject, apiToProject } from '$lib/organizations/types'; import { POLLING_REGULAR } from '$lib/polling'; -import type { HttpClient } from '$lib/httpClient'; +import type { HttpClient } from '$lib/network/httpClient'; import type { AppDispatch } from '$lib/redux/store.svelte'; export class ProjectService { diff --git a/packages/shared/src/lib/organizations/types.ts b/packages/shared/src/lib/organizations/types.ts index a69dd679d4..1470760e56 100644 --- a/packages/shared/src/lib/organizations/types.ts +++ b/packages/shared/src/lib/organizations/types.ts @@ -1,3 +1,5 @@ +import type { LoadableData } from '$lib/network/loadable'; + export type ApiProject = { slug: string; owner: string; @@ -78,6 +80,8 @@ export type Organization = { projectRepositoryIds?: string[]; }; +export type LoadableOrganization = LoadableData; + export function apiToOrganization( apiOrganization: ApiOrganization | ApiOrganizationWithDetails ): Organization { diff --git a/packages/shared/src/lib/users/userService.ts b/packages/shared/src/lib/users/userService.ts index 7b4ebcdcad..32f61d436f 100644 --- a/packages/shared/src/lib/users/userService.ts +++ b/packages/shared/src/lib/users/userService.ts @@ -2,7 +2,7 @@ import { InterestStore, type Interest } from '$lib/interest/intrestStore'; import { POLLING_SLOW } from '$lib/polling'; import { apiToUser, type ApiUser } from '$lib/users/types'; import { upsertUser } from '$lib/users/usersSlice'; -import type { HttpClient } from '$lib/httpClient'; +import type { HttpClient } from '$lib/network/httpClient'; import type { AppDispatch } from '$lib/redux/store.svelte'; export class UserService {