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..044a61df2b 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';
@@ -25,14 +25,17 @@ export function projectCloudSync(
registerInterest(cloudProjectInterest);
});
- const cloudProject = $derived(
+ const loadableCloudProject = $derived(
project.current?.api
? projectsSelectors.selectById(appState.projects, project.current.api.repository_id)
: undefined
);
$effect(() => {
- if (!project.current?.api || !cloudProject) return;
+ if (!project.current?.api || !loadableCloudProject || loadableCloudProject.status !== 'found')
+ return;
+
+ const cloudProject = loadableCloudProject.value;
const persistedProjectUpdatedAt = new Date(project.current.api.updated_at).getTime();
const cloudProjectUpdatedAt = new Date(cloudProject.updatedAt).getTime();
if (persistedProjectUpdatedAt >= cloudProjectUpdatedAt) return;
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/feeds/CreatePost.svelte b/apps/desktop/src/lib/feeds/CreatePost.svelte
index dd16578fde..6055a17994 100644
--- a/apps/desktop/src/lib/feeds/CreatePost.svelte
+++ b/apps/desktop/src/lib/feeds/CreatePost.svelte
@@ -73,13 +73,13 @@
// Post creation
let newPostContent = $derived(persisted('', `postContent--${feedIdentity}--${replyTo}`));
function createPost() {
- if (!feedIdentity?.current) return;
- if (!parentProject?.current) return;
+ if (feedIdentity?.current.status !== 'found') return;
+ if (parentProject?.current?.status !== 'found') return;
feedService.createPost(
$newPostContent,
- parentProject.current.repositoryId,
- feedIdentity.current,
+ parentProject.current.value.repositoryId,
+ feedIdentity.current.value,
replyTo,
picture
);
diff --git a/apps/desktop/src/lib/feeds/Post.svelte b/apps/desktop/src/lib/feeds/Post.svelte
index 6e06c6205c..52b10cf6ae 100644
--- a/apps/desktop/src/lib/feeds/Post.svelte
+++ b/apps/desktop/src/lib/feeds/Post.svelte
@@ -6,6 +6,7 @@
import { postsSelectors } from '@gitbutler/shared/feeds/postsSlice';
import { FeedService } from '@gitbutler/shared/feeds/service';
import { registerInterestInView } from '@gitbutler/shared/interest/registerInterestFunction.svelte';
+ import Loading from '@gitbutler/shared/network/Loading.svelte';
import { AppState } from '@gitbutler/shared/redux/store.svelte';
import { UserService } from '@gitbutler/shared/users/userService';
import Button from '@gitbutler/ui/Button.svelte';
@@ -42,12 +43,16 @@
-
-
{author.current?.name}
+
+ {#snippet children(author)}
+
+ {author.name}
+ {/snippet}
+
diff --git a/apps/desktop/src/lib/settings/userPreferences/CloudProjectSettings.svelte b/apps/desktop/src/lib/settings/userPreferences/CloudProjectSettings.svelte
index c36d0f684c..c7094b547a 100644
--- a/apps/desktop/src/lib/settings/userPreferences/CloudProjectSettings.svelte
+++ b/apps/desktop/src/lib/settings/userPreferences/CloudProjectSettings.svelte
@@ -7,6 +7,7 @@
import * as toasts from '$lib/utils/toasts';
import { getContext, getContextStore } from '@gitbutler/shared/context';
import RegisterInterest from '@gitbutler/shared/interest/RegisterInterest.svelte';
+ import Loading from '@gitbutler/shared/network/Loading.svelte';
import { OrganizationService } from '@gitbutler/shared/organizations/organizationService';
import { organizationsSelectors } from '@gitbutler/shared/organizations/organizationsSlice';
import { ProjectService as CloudProjectService } from '@gitbutler/shared/organizations/projectService';
@@ -138,36 +139,44 @@
{/snippet}
- {#if !cloudProject?.parentProjectRepositoryId}
-
- {#snippet title()}
- Link your project with an organization
- {/snippet}
-
-
-
-
- {#each usersOrganizations as organization, index}
-
- {#snippet children()}
- {organization.name || organization.slug}
- {/snippet}
- {#snippet actions()}
-
- {/snippet}
-
- {/each}
-
-
- {/if}
+
+ {#snippet children(cloudProject)}
+
+ {#snippet title()}
+ Link your project with an organization
+ {/snippet}
+
+
+
+
+ {#each usersOrganizations as loadableOrganization, index}
+
+ {#snippet children()}
+
+ {#snippet children(organization)}
+
+ {organization.name || organization.slug}
+
+ {/snippet}
+
+ {/snippet}
+ {#snippet actions()}
+
+ {/snippet}
+
+ {/each}
+
+
+ {/snippet}
+
{:else if !$project?.api?.repository_id}
diff --git a/apps/desktop/src/lib/settings/userPreferences/ProjectConnectModal.svelte b/apps/desktop/src/lib/settings/userPreferences/ProjectConnectModal.svelte
index ad3b64c091..7b47275ede 100644
--- a/apps/desktop/src/lib/settings/userPreferences/ProjectConnectModal.svelte
+++ b/apps/desktop/src/lib/settings/userPreferences/ProjectConnectModal.svelte
@@ -1,6 +1,7 @@
-
+
- {#if organization}
- {#each organizationProjects as { project, projectInterest }, index}
-
-
-
- {#if project}
- {project.name}
-
-
- {:else}
- Loading...
- {/if}
-
- {/each}
- {/if}
+ {#each organizationProjects as { project: organizationProject, interest }, index}
+
+
+
+
+ {#snippet children(organizationProject)}
+ {organizationProject.name}
+
+
+ {/snippet}
+
+
+ {/each}
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 8162b628a3..409da88912 100644
--- a/apps/desktop/src/routes/+layout.ts
+++ b/apps/desktop/src/routes/+layout.ts
@@ -15,7 +15,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 0cc3c3c77a..39d234b264 100644
--- a/apps/desktop/src/routes/[projectId]/+layout.svelte
+++ b/apps/desktop/src/routes/[projectId]/+layout.svelte
@@ -37,7 +37,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/[projectId]/feed/+page.svelte b/apps/desktop/src/routes/[projectId]/feed/+page.svelte
index 9a35090ef7..748c2bd6e0 100644
--- a/apps/desktop/src/routes/[projectId]/feed/+page.svelte
+++ b/apps/desktop/src/routes/[projectId]/feed/+page.svelte
@@ -22,18 +22,25 @@
: undefined
);
- const feed = $derived(getFeed(appState, feedService, feedIdentity?.current));
+ const feed = $derived.by(() => {
+ if (feedIdentity?.current.status !== 'found') return;
+ return getFeed(appState, feedService, feedIdentity?.current.value);
+ });
// Infinite scrolling
- const lastPost = $derived(getFeedLastPost(appState, feedService, feed.current));
+ const lastPost = $derived(getFeedLastPost(appState, feedService, feed?.current));
let lastElement = $state();
$effect(() => {
if (!lastElement) return;
const observer = new IntersectionObserver((entries) => {
- if (entries[0]?.isIntersecting && lastPost.current?.createdAt && feedIdentity?.current) {
- feedService.getFeedPage(feedIdentity.current, lastPost.current.createdAt);
+ if (
+ entries[0]?.isIntersecting &&
+ lastPost.current?.createdAt &&
+ feedIdentity?.current.status === 'found'
+ ) {
+ feedService.getFeedPage(feedIdentity.current.value, lastPost.current.createdAt);
}
});
@@ -51,7 +58,7 @@
- {#if feed.current}
+ {#if feed?.current}
{#each feed.current.postIds as postId, index (postId)}
{#if index < feed.current.postIds.length - 1 && lastPost.current && feedIdentity?.current}
diff --git a/apps/desktop/src/routes/[projectId]/feed/[postId]/+page.svelte b/apps/desktop/src/routes/[projectId]/feed/[postId]/+page.svelte
index 38d1af2d26..2a311e0009 100644
--- a/apps/desktop/src/routes/[projectId]/feed/[postId]/+page.svelte
+++ b/apps/desktop/src/routes/[projectId]/feed/[postId]/+page.svelte
@@ -8,6 +8,7 @@
import { postsSelectors } from '@gitbutler/shared/feeds/postsSlice';
import { FeedService } from '@gitbutler/shared/feeds/service';
import { registerInterest } from '@gitbutler/shared/interest/registerInterestFunction.svelte';
+ import Loading from '@gitbutler/shared/network/Loading.svelte';
import { AppState } from '@gitbutler/shared/redux/store.svelte';
import { UserService } from '@gitbutler/shared/users/userService';
import Button from '@gitbutler/ui/Button.svelte';
@@ -48,12 +49,16 @@
-
-
{author?.current?.name}
+
+ {#snippet children(author)}
+
+ {author.name}
+ {/snippet}
+
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}
+ Uninitialized...
+{:else if loadable.status === 'found'}
+ {@render children(loadable.value)}
+{:else if loadable.status === 'loading'}
+ Loading...
+{:else if loadable.status === 'not-found'}
+ Not found
+{:else if loadable.status === 'error'}
+ {loadable.error.message}
+{:else}
+ Unknown state
+{/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..6d6ba4c9ef
--- /dev/null
+++ b/packages/shared/src/lib/network/loadable.ts
@@ -0,0 +1,62 @@
+import { ApiError, type Loadable, type LoadableData } from '$lib/network/types';
+import type { EntityId, EntityAdapter, EntityState } from '@reduxjs/toolkit';
+
+export function isFound(loadable?: Loadable): loadable is {
+ status: 'found';
+ value: T;
+} {
+ return loadable?.status === 'found';
+}
+
+export function errorToLoadable(error: unknown, id: Id): LoadableData {
+ if (error instanceof Error) {
+ if (error instanceof ApiError && error.response.status === 404) {
+ return { status: 'not-found', id };
+ }
+
+ return { status: 'error', id, error };
+ }
+
+ return { status: 'error', id, error: new Error(String(error)) };
+}
+
+export function loadableUpsert(
+ adapter: EntityAdapter, Id>
+) {
+ return (
+ state: EntityState, Id>,
+ action: { payload: LoadableData }
+ ) => {
+ loadableUpsertMany(adapter)(state, { payload: [action.payload] });
+ };
+}
+
+export function loadableUpsertMany(
+ adapter: EntityAdapter, Id>
+) {
+ return (
+ state: EntityState, Id>,
+ action: { payload: LoadableData[] }
+ ) => {
+ const values = action.payload.map((payload) => {
+ const value = state.entities[payload.id];
+ if (value === undefined) {
+ return payload;
+ }
+
+ if (!(value.status === 'found' && payload.status === 'found')) {
+ return payload;
+ }
+
+ const newValue: LoadableData = {
+ status: 'found',
+ id: payload.id,
+ value: { ...value, ...payload.value }
+ };
+
+ return newValue;
+ });
+
+ adapter.setMany(state, values);
+ };
+}
diff --git a/packages/shared/src/lib/network/types.ts b/packages/shared/src/lib/network/types.ts
new file mode 100644
index 0000000000..11063c34c7
--- /dev/null
+++ b/packages/shared/src/lib/network/types.ts
@@ -0,0 +1,15 @@
+export class ApiError extends Error {
+ constructor(
+ message: string,
+ readonly response: Response
+ ) {
+ super(message);
+ }
+}
+
+export type Loadable =
+ | { status: 'loading' | 'not-found' }
+ | { status: 'found'; value: T }
+ | { status: 'error'; error: Error };
+
+export type LoadableData = Loadable & { id: Id };
diff --git a/packages/shared/src/lib/organizations/OrganizationModal.svelte b/packages/shared/src/lib/organizations/OrganizationModal.svelte
index 8a8f953a08..c6b758f507 100644
--- a/packages/shared/src/lib/organizations/OrganizationModal.svelte
+++ b/packages/shared/src/lib/organizations/OrganizationModal.svelte
@@ -1,21 +1,19 @@
-
-
-
- Users:
- {#if organization?.inviteCode}
-
- {/if}
-
-
- {#each users as user, index}
-
-
-
-
- {user.user?.name}
-
- {/each}
-
-
- Projects:
-
- {#each projects as { project, projectInterest }, index}
-
-
-
- {project?.name}
-
- {/each}
-
+
+
+ {#snippet children(organization)}
+ {#if organization.inviteCode}
+
+ {/if}
+
+ {#if organization.memberLogins}
+ Users:
+
+
+ {#each organization.memberLogins as login, index}
+ {@const user = getUserByLogin(appState, userService, login)}
+
+
+
+ {#snippet children(user)}
+
+ {user?.name}
+ {/snippet}
+
+
+ {/each}
+
+ {/if}
+
+ {#if organization.projectRepositoryIds}
+ Projects:
+
+ {#each organization.projectRepositoryIds as repositoryId, index}
+ {@const project = getProjectByRepositoryId(appState, projectService, repositoryId)}
+
+
+
+ {#snippet children(project)}
+ {project.name}
+ {/snippet}
+
+
+ {/each}
+
+ {/if}
+ {/snippet}
+
diff --git a/packages/shared/src/lib/organizations/organizationService.ts b/packages/shared/src/lib/organizations/organizationService.ts
index 877f8bde3d..8f382ecb3c 100644
--- a/packages/shared/src/lib/organizations/organizationService.ts
+++ b/packages/shared/src/lib/organizations/organizationService.ts
@@ -1,15 +1,22 @@
import { InterestStore, type Interest } from '$lib/interest/intrestStore';
-import { upsertOrganization, upsertOrganizations } from '$lib/organizations/organizationsSlice';
+import { type HttpClient } from '$lib/network/httpClient';
+import { errorToLoadable } from '$lib/network/loadable';
+import {
+ addOrganization,
+ upsertOrganization,
+ upsertOrganizations
+} from '$lib/organizations/organizationsSlice';
import { upsertProjects } from '$lib/organizations/projectsSlice';
import {
apiToOrganization,
apiToProject,
type ApiOrganization,
type ApiOrganizationWithDetails,
+ type LoadableOrganization,
+ type LoadableProject,
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 +32,11 @@ 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) => ({
+ status: 'found',
+ id: apiOrganizations.slug,
+ value: apiToOrganization(apiOrganizations)
+ }));
this.appDispatch.dispatch(upsertOrganizations(organizations));
})
@@ -35,14 +46,30 @@ 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({ status: 'loading', id: slug }));
+
+ try {
+ const apiOrganization = await this.httpClient.get(
+ `organization/${slug}`
+ );
+
+ const projects = apiOrganization.projects.map((apiProject) => ({
+ status: 'found',
+ id: apiProject.repository_id,
+ value: apiToProject(apiProject)
+ }));
+ this.appDispatch.dispatch(upsertProjects(projects));
- this.appDispatch.dispatch(upsertOrganization(organization));
- this.appDispatch.dispatch(upsertProjects(projects));
+ this.appDispatch.dispatch(
+ upsertOrganization({
+ status: 'found',
+ id: slug,
+ value: apiToOrganization(apiOrganization)
+ })
+ );
+ } catch (error: unknown) {
+ this.appDispatch.dispatch(upsertOrganization(errorToLoadable(error, slug)));
+ }
})
.createInterest();
}
@@ -59,10 +86,12 @@ export class OrganizationService {
description
}
});
- const orgnaization = apiToOrganization(apiOrganization);
- this.appDispatch.dispatch(upsertOrganization(orgnaization));
+ const organization = apiToOrganization(apiOrganization);
+ this.appDispatch.dispatch(
+ upsertOrganization({ status: 'found', id: slug, value: organization })
+ );
- return orgnaization;
+ return organization;
}
async joinOrganization(slug: string, joinCode: string) {
@@ -73,9 +102,11 @@ export class OrganizationService {
}
);
- const orgnaization = apiToOrganization(apiOrganization);
- this.appDispatch.dispatch(upsertOrganization(orgnaization));
+ const organization = apiToOrganization(apiOrganization);
+ this.appDispatch.dispatch(
+ upsertOrganization({ status: 'found', id: slug, value: organization })
+ );
- return orgnaization;
+ return organization;
}
}
diff --git a/packages/shared/src/lib/organizations/organizationsPreview.svelte.ts b/packages/shared/src/lib/organizations/organizationsPreview.svelte.ts
new file mode 100644
index 0000000000..6f16cb7627
--- /dev/null
+++ b/packages/shared/src/lib/organizations/organizationsPreview.svelte.ts
@@ -0,0 +1,21 @@
+import { registerInterest } from '$lib/interest/registerInterestFunction.svelte';
+import { organizationsSelectors } from '$lib/organizations/organizationsSlice';
+import type { OrganizationService } from '$lib/organizations/organizationService';
+import type { LoadableOrganization } from '$lib/organizations/types';
+import type { AppOrganizationsState } from '$lib/redux/store.svelte';
+import type { Reactive } from '$lib/storeUtils';
+
+export function getOrganizationBySlug(
+ appState: AppOrganizationsState,
+ organizationService: OrganizationService,
+ slug: string
+): Reactive {
+ registerInterest(organizationService.getOrganizationWithDetailsInterest(slug));
+ const current = $derived(organizationsSelectors.selectById(appState.organizations, slug));
+
+ return {
+ get current() {
+ return current;
+ }
+ };
+}
diff --git a/packages/shared/src/lib/organizations/organizationsSlice.ts b/packages/shared/src/lib/organizations/organizationsSlice.ts
index 87883f2235..b654fd502a 100644
--- a/packages/shared/src/lib/organizations/organizationsSlice.ts
+++ b/packages/shared/src/lib/organizations/organizationsSlice.ts
@@ -1,9 +1,9 @@
+import { loadableUpsert, loadableUpsertMany } from '$lib/network/loadable';
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({
@@ -14,8 +14,8 @@ const organizationsSlice = createSlice({
addOrganizations: organizationsAdapter.addMany,
removeOrganization: organizationsAdapter.removeOne,
removeOrganizations: organizationsAdapter.removeMany,
- upsertOrganization: organizationsAdapter.upsertOne,
- upsertOrganizations: organizationsAdapter.upsertMany
+ upsertOrganization: loadableUpsert(organizationsAdapter),
+ upsertOrganizations: loadableUpsertMany(organizationsAdapter)
}
});
diff --git a/packages/shared/src/lib/organizations/projectService.ts b/packages/shared/src/lib/organizations/projectService.ts
index d6913ee296..1b7f8b4a23 100644
--- a/packages/shared/src/lib/organizations/projectService.ts
+++ b/packages/shared/src/lib/organizations/projectService.ts
@@ -1,8 +1,9 @@
import { InterestStore, type Interest } from '$lib/interest/intrestStore';
-import { upsertProject } from '$lib/organizations/projectsSlice';
+import { errorToLoadable } from '$lib/network/loadable';
+import { addProject, 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 {
@@ -16,10 +17,17 @@ export class ProjectService {
getProjectInterest(repositoryId: string): Interest {
return this.projectInterests
.findOrCreateSubscribable({ repositoryId }, async () => {
- const apiProject = await this.httpClient.get(`projects/${repositoryId}`);
- const project = apiToProject(apiProject);
+ this.appDispatch.dispatch(addProject({ status: 'loading', id: repositoryId }));
- this.appDispatch.dispatch(upsertProject(project));
+ try {
+ const apiProject = await this.httpClient.get(`projects/${repositoryId}`);
+
+ this.appDispatch.dispatch(
+ upsertProject({ status: 'found', id: repositoryId, value: apiToProject(apiProject) })
+ );
+ } catch (error: unknown) {
+ this.appDispatch.dispatch(upsertProject(errorToLoadable(error, repositoryId)));
+ }
})
.createInterest();
}
@@ -30,7 +38,10 @@ export class ProjectService {
});
const project = apiToProject(apiProject);
- this.appDispatch.dispatch(upsertProject(project));
+ this.appDispatch.dispatch(
+ upsertProject({ status: 'found', id: project.repositoryId, value: project })
+ );
+
return project;
}
@@ -47,7 +58,10 @@ export class ProjectService {
});
const project = apiToProject(apiProject);
- this.appDispatch.dispatch(upsertProject(project));
+ this.appDispatch.dispatch(
+ upsertProject({ status: 'found', id: project.repositoryId, value: project })
+ );
+
return project;
}
}
diff --git a/packages/shared/src/lib/organizations/projectsPreview.svelte.ts b/packages/shared/src/lib/organizations/projectsPreview.svelte.ts
index 85ca3d6b1f..673ae01658 100644
--- a/packages/shared/src/lib/organizations/projectsPreview.svelte.ts
+++ b/packages/shared/src/lib/organizations/projectsPreview.svelte.ts
@@ -1,28 +1,47 @@
import { registerInterest } from '$lib/interest/registerInterestFunction.svelte';
+import { isFound } from '$lib/network/loadable';
import { projectsSelectors } from '$lib/organizations/projectsSlice';
+import type { Loadable } from '$lib/network/types';
import type { ProjectService } from '$lib/organizations/projectService';
-import type { Project } from '$lib/organizations/types';
+import type { LoadableProject } from '$lib/organizations/types';
import type { AppOrganizationsState, AppProjectsState } from '$lib/redux/store.svelte';
import type { Reactive } from '$lib/storeUtils';
+export function getProjectByRepositoryId(
+ appState: AppProjectsState,
+ projectService: ProjectService,
+ projectRepositoryId: string
+): Reactive {
+ registerInterest(projectService.getProjectInterest(projectRepositoryId));
+ const current = $derived(projectsSelectors.selectById(appState.projects, projectRepositoryId));
+
+ return {
+ get current() {
+ return current;
+ }
+ };
+}
+
export function getParentForRepositoryId(
appState: AppProjectsState & AppOrganizationsState,
projectService: ProjectService,
projectRepositoryId: string
-): Reactive {
+): Reactive {
const current = $derived.by(() => {
- registerInterest(projectService.getProjectInterest(projectRepositoryId));
- const project = projectsSelectors.selectById(appState.projects, projectRepositoryId);
+ const project = getProjectByRepositoryId(appState, projectService, projectRepositoryId);
- if (!project || !project.parentProjectRepositoryId) return;
+ if (!isFound(project.current) || !project.current.value.parentProjectRepositoryId) return;
- registerInterest(projectService.getProjectInterest(project.parentProjectRepositoryId));
- return projectsSelectors.selectById(appState.projects, project.parentProjectRepositoryId);
+ return getProjectByRepositoryId(
+ appState,
+ projectService,
+ project.current.value.parentProjectRepositoryId
+ );
});
return {
get current() {
- return current;
+ return current?.current;
}
};
}
@@ -31,15 +50,18 @@ export function getFeedIdentityForRepositoryId(
appState: AppProjectsState & AppOrganizationsState,
projectService: ProjectService,
projectRepositoryId: string
-): Reactive {
+): Reactive> {
const parentProject = $derived(
getParentForRepositoryId(appState, projectService, projectRepositoryId)
);
- const current = $derived.by(() => {
- if (!parentProject.current) return;
+ const current = $derived.by>(() => {
+ if (!isFound(parentProject.current)) return parentProject.current || { status: 'loading' };
- return `${parentProject.current.owner}/${parentProject.current.slug}`;
+ return {
+ status: 'found',
+ value: `${parentProject.current.value.owner}/${parentProject.current.value.slug}`
+ };
});
return {
diff --git a/packages/shared/src/lib/organizations/projectsSlice.ts b/packages/shared/src/lib/organizations/projectsSlice.ts
index d7faa810bf..b4933fe3d4 100644
--- a/packages/shared/src/lib/organizations/projectsSlice.ts
+++ b/packages/shared/src/lib/organizations/projectsSlice.ts
@@ -1,9 +1,9 @@
+import { loadableUpsert, loadableUpsertMany } from '$lib/network/loadable';
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
-import type { Project } from '$lib/organizations/types';
+import type { LoadableProject } from '$lib/organizations/types';
-const projectsAdapter = createEntityAdapter({
- selectId: (project: Project) => project.repositoryId,
- sortComparer: (a: Project, b: Project) => a.slug.localeCompare(b.slug)
+const projectsAdapter = createEntityAdapter({
+ selectId: (project: LoadableProject) => project.id
});
const projectsSlice = createSlice({
@@ -14,8 +14,8 @@ const projectsSlice = createSlice({
addProjects: projectsAdapter.addMany,
removeProject: projectsAdapter.removeOne,
removeProjects: projectsAdapter.removeMany,
- upsertProject: projectsAdapter.upsertOne,
- upsertProjects: projectsAdapter.upsertMany
+ upsertProject: loadableUpsert(projectsAdapter),
+ upsertProjects: loadableUpsertMany(projectsAdapter)
}
});
diff --git a/packages/shared/src/lib/organizations/types.ts b/packages/shared/src/lib/organizations/types.ts
index a69dd679d4..acf8342b5e 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/types';
+
export type ApiProject = {
slug: string;
owner: string;
@@ -32,6 +34,8 @@ export type Project = {
updatedAt: string;
};
+export type LoadableProject = LoadableData;
+
export function apiToProject(apiProject: ApiProject): Project {
return {
repositoryId: apiProject.repository_id,
@@ -78,6 +82,8 @@ export type Organization = {
projectRepositoryIds?: string[];
};
+export type LoadableOrganization = LoadableData;
+
export function apiToOrganization(
apiOrganization: ApiOrganization | ApiOrganizationWithDetails
): Organization {
diff --git a/packages/shared/src/lib/reactiveUtils.svelte.ts b/packages/shared/src/lib/reactiveUtils.svelte.ts
index 773855430a..6a0529099c 100644
--- a/packages/shared/src/lib/reactiveUtils.svelte.ts
+++ b/packages/shared/src/lib/reactiveUtils.svelte.ts
@@ -1,5 +1,5 @@
-import type { Reactive } from '$lib/storeUtils';
-import type { Readable } from 'svelte/store';
+import type { Reactive, WritableReactive } from '$lib/storeUtils';
+import type { Readable, Writable } from 'svelte/store';
export function readableToReactive(readable: Readable): Reactive {
let current = $state();
@@ -18,3 +18,27 @@ export function readableToReactive(readable: Readable): Reactive(writable: Writable): WritableReactive {
+ let current = $state();
+
+ $effect(() => {
+ const unsubscribe = writable.subscribe((value) => {
+ current = value;
+ });
+
+ return unsubscribe;
+ });
+
+ return {
+ get current() {
+ return current;
+ },
+
+ set current(value: T | undefined) {
+ if (value !== undefined) {
+ writable.set(value);
+ }
+ }
+ };
+}
diff --git a/packages/shared/src/lib/storeUtils.ts b/packages/shared/src/lib/storeUtils.ts
index 073e7e7bbf..9500554dd8 100644
--- a/packages/shared/src/lib/storeUtils.ts
+++ b/packages/shared/src/lib/storeUtils.ts
@@ -56,7 +56,8 @@ export function writableDerived(
return derivedStore;
}
-export type Reactive = { current: T };
+export type Reactive = { readonly current: T };
+export type WritableReactive = { current: T };
export async function guardReadableTrue(target: Readable): Promise {
return await new Promise((resolve) => {
diff --git a/packages/shared/src/lib/users/types.ts b/packages/shared/src/lib/users/types.ts
index 01e3cec2a6..670c819033 100644
--- a/packages/shared/src/lib/users/types.ts
+++ b/packages/shared/src/lib/users/types.ts
@@ -1,3 +1,5 @@
+import type { LoadableData } from '$lib/network/types';
+
export type ApiUser = {
id: number;
login: string;
@@ -13,6 +15,8 @@ export type User = {
avatarUrl?: string;
};
+export type LoadableUser = LoadableData;
+
export function apiToUser(apiUser: ApiUser): User {
return {
login: apiUser.login,
diff --git a/packages/shared/src/lib/users/userService.ts b/packages/shared/src/lib/users/userService.ts
index 7b4ebcdcad..cbed3df6fa 100644
--- a/packages/shared/src/lib/users/userService.ts
+++ b/packages/shared/src/lib/users/userService.ts
@@ -1,8 +1,9 @@
import { InterestStore, type Interest } from '$lib/interest/intrestStore';
+import { errorToLoadable } from '$lib/network/loadable';
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 { addUser, upsertUser } from '$lib/users/usersSlice';
+import type { HttpClient } from '$lib/network/httpClient';
import type { AppDispatch } from '$lib/redux/store.svelte';
export class UserService {
@@ -16,9 +17,15 @@ export class UserService {
getUserInterest(login: string): Interest {
return this.userInterests
.findOrCreateSubscribable({ login }, async () => {
- const apiUser = await this.httpClient.get(`user/${login}`);
- const user = apiToUser(apiUser);
- this.appDispatch.dispatch(upsertUser(user));
+ this.appDispatch.dispatch(addUser({ status: 'loading', id: login }));
+
+ try {
+ const apiUser = await this.httpClient.get(`user/${login}`);
+ const user = apiToUser(apiUser);
+ this.appDispatch.dispatch(upsertUser({ status: 'found', id: login, value: user }));
+ } catch (error: unknown) {
+ this.appDispatch.dispatch(upsertUser(errorToLoadable(error, login)));
+ }
})
.createInterest();
}
diff --git a/packages/shared/src/lib/users/usersPreview.svelte.ts b/packages/shared/src/lib/users/usersPreview.svelte.ts
new file mode 100644
index 0000000000..5af6ffce4a
--- /dev/null
+++ b/packages/shared/src/lib/users/usersPreview.svelte.ts
@@ -0,0 +1,21 @@
+import { registerInterest } from '$lib/interest/registerInterestFunction.svelte';
+import { UserService } from '$lib/users/userService';
+import { usersSelectors } from '$lib/users/usersSlice';
+import type { AppUsersState } from '$lib/redux/store.svelte';
+import type { Reactive } from '$lib/storeUtils';
+import type { LoadableUser } from '$lib/users/types';
+
+export function getUserByLogin(
+ appState: AppUsersState,
+ userService: UserService,
+ login: string
+): Reactive {
+ registerInterest(userService.getUserInterest(login));
+ const current = $derived(usersSelectors.selectById(appState.users, login));
+
+ return {
+ get current() {
+ return current;
+ }
+ };
+}
diff --git a/packages/shared/src/lib/users/usersSlice.ts b/packages/shared/src/lib/users/usersSlice.ts
index 641aa450cd..7c7cc91770 100644
--- a/packages/shared/src/lib/users/usersSlice.ts
+++ b/packages/shared/src/lib/users/usersSlice.ts
@@ -1,9 +1,9 @@
+import { loadableUpsert, loadableUpsertMany } from '$lib/network/loadable';
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';
-import type { User } from '$lib/users/types';
+import type { LoadableUser } from '$lib/users/types';
-const usersAdapter = createEntityAdapter({
- selectId: (user: User) => user.login,
- sortComparer: (a: User, b: User) => a.login.localeCompare(b.login)
+const usersAdapter = createEntityAdapter({
+ selectId: (user: LoadableUser) => user.id
});
const usersSlice = createSlice({
@@ -14,8 +14,8 @@ const usersSlice = createSlice({
addUsers: usersAdapter.addMany,
removeUser: usersAdapter.removeOne,
removeUsers: usersAdapter.removeMany,
- upsertUser: usersAdapter.upsertOne,
- upsertUsers: usersAdapter.upsertMany
+ upsertUser: loadableUpsert(usersAdapter),
+ upsertUsers: loadableUpsertMany(usersAdapter)
}
});