From 1f37d10f7a9ee15a19bc16bf66278054453781db Mon Sep 17 00:00:00 2001 From: Caleb Owens Date: Thu, 9 Jan 2025 17:57:19 +0100 Subject: [PATCH] Patch APIS --- apps/desktop/src/lib/redux/store.svelte.ts | 2 + apps/desktop/src/routes/+layout.svelte | 3 + apps/web/src/routes/+layout.svelte | 4 +- .../shared/src/lib/branches/branchService.ts | 39 +++++- .../lib/branches/branchesPreview.svelte.ts | 44 +++++++ .../src/lib/branches/patchSectionsSlice.ts | 31 +++++ .../shared/src/lib/branches/patchService.ts | 72 +++++++++++ .../src/lib/branches/patchesPreview.svelte.ts | 52 ++++++++ packages/shared/src/lib/branches/types.ts | 112 +++++++++++++++++- .../registerInterestFunction.svelte.ts | 14 ++- packages/shared/src/lib/redux/store.svelte.ts | 16 ++- 11 files changed, 382 insertions(+), 7 deletions(-) create mode 100644 packages/shared/src/lib/branches/branchesPreview.svelte.ts create mode 100644 packages/shared/src/lib/branches/patchSectionsSlice.ts create mode 100644 packages/shared/src/lib/branches/patchService.ts create mode 100644 packages/shared/src/lib/branches/patchesPreview.svelte.ts diff --git a/apps/desktop/src/lib/redux/store.svelte.ts b/apps/desktop/src/lib/redux/store.svelte.ts index 1ed83ea82e..dd054021a2 100644 --- a/apps/desktop/src/lib/redux/store.svelte.ts +++ b/apps/desktop/src/lib/redux/store.svelte.ts @@ -1,4 +1,5 @@ import { branchesReducer } from '@gitbutler/shared/branches/branchesSlice'; +import { patchSectionsReducer } from '@gitbutler/shared/branches/patchSectionsSlice'; import { patchesReducer } from '@gitbutler/shared/branches/patchesSlice'; import { feedsReducer } from '@gitbutler/shared/feeds/feedsSlice'; import { postsReducer } from '@gitbutler/shared/feeds/postsSlice'; @@ -59,6 +60,7 @@ export class DesktopState extends AppState implements AppDesktopOnlyState { projects: projectsReducer, branches: branchesReducer, patches: patchesReducer, + patchSections: patchSectionsReducer, desktopOnly: desktopOnly.reducer } }); diff --git a/apps/desktop/src/routes/+layout.svelte b/apps/desktop/src/routes/+layout.svelte index eb3ccc838e..c62bfa633a 100644 --- a/apps/desktop/src/routes/+layout.svelte +++ b/apps/desktop/src/routes/+layout.svelte @@ -39,6 +39,7 @@ import * as events from '$lib/utils/events'; import { unsubscribe } from '$lib/utils/unsubscribe'; import { BranchService as CloudBranchService } from '@gitbutler/shared/branches/branchService'; + import { PatchService as CloudPatchService } from '@gitbutler/shared/branches/patchService'; import { FeedService } from '@gitbutler/shared/feeds/service'; import { HttpClient } from '@gitbutler/shared/network/httpClient'; import { OrganizationService } from '@gitbutler/shared/organizations/organizationService'; @@ -72,6 +73,7 @@ const cloudUserService = new CloudUserService(data.cloud, appState.appDispatch); const cloudProjectService = new CloudProjectService(data.cloud, appState.appDispatch); const cloudBranchService = new CloudBranchService(data.cloud, appState.appDispatch); + const cloudPatchService = new CloudPatchService(data.cloud, appState.appDispatch); setContext(AppState, appState); setContext(AppDispatch, appState.appDispatch); @@ -82,6 +84,7 @@ setContext(CloudUserService, cloudUserService); setContext(CloudProjectService, cloudProjectService); setContext(CloudBranchService, cloudBranchService); + setContext(CloudPatchService, cloudPatchService); // Setters do not need to be reactive since `data` never updates setSecretsService(data.secretsService); diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 03111bf65d..57ddf1c248 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import Navigation from '$lib/components/Navigation.svelte'; import { UserService } from '$lib/user/userService'; import { BranchService } from '@gitbutler/shared/branches/branchService'; + import { PatchService } from '@gitbutler/shared/branches/patchService'; import { FeedService } from '@gitbutler/shared/feeds/service'; import { HttpClient } from '@gitbutler/shared/network/httpClient'; import { OrganizationService } from '@gitbutler/shared/organizations/organizationService'; @@ -46,9 +47,10 @@ setContext(ProjectService, projectService); const newUserService = new NewUserService(httpClient, appState.appDispatch); setContext(NewUserService, newUserService); - const branchService = new BranchService(httpClient, appState.appDispatch); setContext(BranchService, branchService); + const patchSerice = new PatchService(httpClient, appState.appDispatch); + setContext(PatchService, patchSerice); $effect(() => { const token = get(authService.token) || $page.url.searchParams.get('gb_access_token'); diff --git a/packages/shared/src/lib/branches/branchService.ts b/packages/shared/src/lib/branches/branchService.ts index 43ed40356f..3b53db50f0 100644 --- a/packages/shared/src/lib/branches/branchService.ts +++ b/packages/shared/src/lib/branches/branchService.ts @@ -1,4 +1,4 @@ -import { upsertBranch, upsertBranches } from '$lib/branches/branchesSlice'; +import { addBranch, upsertBranch, upsertBranches } from '$lib/branches/branchesSlice'; import { upsertPatches } from '$lib/branches/patchesSlice'; import { apiToBranch, @@ -10,6 +10,7 @@ import { type LoadablePatch } from '$lib/branches/types'; import { InterestStore, type Interest } from '$lib/interest/intrestStore'; +import { errorToLoadable } from '$lib/network/loadable'; import { POLLING_GLACIALLY, POLLING_REGULAR } from '$lib/polling'; import type { HttpClient } from '$lib/network/httpClient'; import type { AppDispatch } from '$lib/redux/store.svelte'; @@ -32,7 +33,10 @@ export class BranchService { private readonly appDispatch: AppDispatch ) {} - getBranches(repositoryId: string, branchStatus: BranchStatus = BranchStatus.All): Interest { + getBranchesInterest( + repositoryId: string, + branchStatus: BranchStatus = BranchStatus.All + ): Interest { return this.branchesInterests .findOrCreateSubscribable({ repositoryId, branchStatus }, async () => { try { @@ -66,6 +70,37 @@ export class BranchService { .createInterest(); } + getBranchInterest(repositoryId: string, branchId: string): Interest { + return this.branchInterests + .findOrCreateSubscribable({ branchId }, async () => { + this.appDispatch.dispatch(addBranch({ status: 'loading', id: branchId })); + try { + const apiBranch = await this.httpClient.get( + `patch_stack/${repositoryId}/${branchId}` + ); + const branch: LoadableBranch = { + status: 'found', + id: apiBranch.branch_id, + value: apiToBranch(apiBranch) + }; + + const patches = apiBranch.patches.map( + (api): LoadablePatch => ({ + status: 'found', + id: api.change_id, + value: apiToPatch(api) + }) + ); + + this.appDispatch.dispatch(upsertBranch(branch)); + this.appDispatch.dispatch(upsertPatches(patches)); + } catch (error: unknown) { + this.appDispatch.dispatch(upsertBranch(errorToLoadable(error, branchId))); + } + }) + .createInterest(); + } + async createBranch(repositoryId: string, branchId: string, oplogSha: string): Promise { const apiBranch = await this.httpClient.post(`patch_stack`, { body: { branch_id: branchId, oplog_sha: oplogSha, project_id: repositoryId } diff --git a/packages/shared/src/lib/branches/branchesPreview.svelte.ts b/packages/shared/src/lib/branches/branchesPreview.svelte.ts new file mode 100644 index 0000000000..96c918ed32 --- /dev/null +++ b/packages/shared/src/lib/branches/branchesPreview.svelte.ts @@ -0,0 +1,44 @@ +import { branchesSelectors } from '$lib/branches/branchesSlice'; +import { BranchStatus, type LoadableBranch } from '$lib/branches/types'; +import { registerInterest, type InView } from '$lib/interest/registerInterestFunction.svelte'; +import type { BranchService } from '$lib/branches/branchService'; +import type { AppBranchesState } from '$lib/redux/store.svelte'; +import type { Reactive } from '$lib/storeUtils'; + +export function getBranchReviews( + appState: AppBranchesState, + branchService: BranchService, + repositoryId: string, + status: BranchStatus = BranchStatus.All, + inView?: InView +): Reactive { + const branchReviewsInterest = branchService.getBranchesInterest(repositoryId, status); + registerInterest(branchReviewsInterest, inView); + + const branchReviews = $derived(branchesSelectors.selectAll(appState.branches)); + + return { + get current() { + return branchReviews; + } + }; +} + +export function getBranchReview( + appState: AppBranchesState, + branchService: BranchService, + repositoryId: string, + branchId: string, + inView?: InView +): Reactive { + const branchReviewInterest = branchService.getBranchInterest(repositoryId, branchId); + registerInterest(branchReviewInterest, inView); + + const branchReview = $derived(branchesSelectors.selectById(appState.branches, branchId)); + + return { + get current() { + return branchReview; + } + }; +} diff --git a/packages/shared/src/lib/branches/patchSectionsSlice.ts b/packages/shared/src/lib/branches/patchSectionsSlice.ts new file mode 100644 index 0000000000..7881a627a2 --- /dev/null +++ b/packages/shared/src/lib/branches/patchSectionsSlice.ts @@ -0,0 +1,31 @@ +import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; +import type { Section } from '$lib/branches/types'; + +const patchSectionsAdapter = createEntityAdapter({ + selectId: (patchSection: Section) => patchSection.id +}); + +const patchSectionsSlice = createSlice({ + name: 'patchSectionSections', + initialState: patchSectionsAdapter.getInitialState(), + reducers: { + addPatchSection: patchSectionsAdapter.addOne, + addPatchSections: patchSectionsAdapter.addMany, + removePatchSection: patchSectionsAdapter.removeOne, + removePatchSections: patchSectionsAdapter.removeMany, + upsertPatchSection: patchSectionsAdapter.upsertOne, + upsertPatchSections: patchSectionsAdapter.upsertMany + } +}); + +export const patchSectionsReducer = patchSectionsSlice.reducer; + +export const patchSectionsSelectors = patchSectionsAdapter.getSelectors(); +export const { + addPatchSection, + addPatchSections, + removePatchSection, + removePatchSections, + upsertPatchSection, + upsertPatchSections +} = patchSectionsSlice.actions; diff --git a/packages/shared/src/lib/branches/patchService.ts b/packages/shared/src/lib/branches/patchService.ts new file mode 100644 index 0000000000..9858e73544 --- /dev/null +++ b/packages/shared/src/lib/branches/patchService.ts @@ -0,0 +1,72 @@ +import { upsertPatchSections } from '$lib/branches/patchSectionsSlice'; +import { addPatch, upsertPatch } from '$lib/branches/patchesSlice'; +import { apiToPatch, apiToSection, type ApiPatch, type Patch } from '$lib/branches/types'; +import { InterestStore, type Interest } from '$lib/interest/intrestStore'; +import { errorToLoadable } from '$lib/network/loadable'; +import { POLLING_REGULAR } from '$lib/polling'; +import type { HttpClient } from '$lib/network/httpClient'; +import type { AppDispatch } from '$lib/redux/store.svelte'; + +type PatchUpdateParams = { + signOff?: boolean; + sectionOrder?: string[]; +}; + +export class PatchService { + private readonly patchInterests = new InterestStore<{ changeId: string }>(POLLING_REGULAR); + + constructor( + private readonly httpClient: HttpClient, + private readonly appDispatch: AppDispatch + ) {} + + getPatchWithSectionsInterest(branchId: string, changeId: string): Interest { + return this.patchInterests + .findOrCreateSubscribable({ changeId }, async () => { + this.appDispatch.dispatch(addPatch({ status: 'loading', id: changeId })); + try { + const apiPatch = await this.httpClient.get( + `patch_stack/${branchId}/patch/${changeId}` + ); + + const patch = apiToPatch(apiPatch); + + // This will always be here, but this makes the typescript + // compiler happy + if (apiPatch.sections) { + const sections = apiPatch.sections.map(apiToSection); + this.appDispatch.dispatch(upsertPatchSections(sections)); + } + + this.appDispatch.dispatch(upsertPatch({ status: 'found', id: changeId, value: patch })); + } catch (error: unknown) { + this.appDispatch.dispatch(upsertPatch(errorToLoadable(error, changeId))); + } + }) + .createInterest(); + } + + async updatePatch(branchId: string, changeId: string, params: PatchUpdateParams): Promise { + const apiPatch = await this.httpClient.patch( + `patch_stack/${branchId}/patch/${changeId}`, + { + body: { + sign_off: params.signOff, + section_order: params.sectionOrder + } + } + ); + + const patch = apiToPatch(apiPatch); + this.appDispatch.dispatch(upsertPatch({ status: 'found', id: changeId, value: patch })); + + // This will always be here, but this makes the typescript + // compiler happy + if (apiPatch.sections) { + const sections = apiPatch.sections.map(apiToSection); + this.appDispatch.dispatch(upsertPatchSections(sections)); + } + + return patch; + } +} diff --git a/packages/shared/src/lib/branches/patchesPreview.svelte.ts b/packages/shared/src/lib/branches/patchesPreview.svelte.ts new file mode 100644 index 0000000000..cf6c986fc0 --- /dev/null +++ b/packages/shared/src/lib/branches/patchesPreview.svelte.ts @@ -0,0 +1,52 @@ +import { patchSectionsSelectors } from '$lib/branches/patchSectionsSlice'; +import { patchesSelectors } from '$lib/branches/patchesSlice'; +import { registerInterest, type InView } from '$lib/interest/registerInterestFunction.svelte'; +import type { PatchService } from '$lib/branches/patchService'; +import type { LoadablePatch, Section } from '$lib/branches/types'; +import type { AppPatchesState, AppPatchSectionsState } from '$lib/redux/store.svelte'; +import type { Reactive } from '$lib/storeUtils'; + +export function getPatch( + appState: AppPatchesState, + patchService: PatchService, + branchId: string, + changeId: string, + inView?: InView +): Reactive { + const patchInterest = patchService.getPatchWithSectionsInterest(branchId, changeId); + registerInterest(patchInterest, inView); + + const patch = $derived(patchesSelectors.selectById(appState.patches, branchId)); + + return { + get current() { + return patch; + } + }; +} + +export function getPatchSections( + appState: AppPatchesState & AppPatchSectionsState, + patchService: PatchService, + branchId: string, + changeId: string, + inView?: InView +): Reactive { + const patchInterest = patchService.getPatchWithSectionsInterest(branchId, changeId); + registerInterest(patchInterest, inView); + + const patch = $derived(patchesSelectors.selectById(appState.patches, branchId)); + const sections = $derived.by(() => { + if (patch?.status !== 'found') return; + + return (patch.value.sectionIds || []) + .map((id) => patchSectionsSelectors.selectById(appState.patchSections, id)) + .filter((a) => a) as Section[]; + }); + + return { + get current() { + return sections; + } + }; +} diff --git a/packages/shared/src/lib/branches/types.ts b/packages/shared/src/lib/branches/types.ts index 70bd50020c..f5e70753f1 100644 --- a/packages/shared/src/lib/branches/types.ts +++ b/packages/shared/src/lib/branches/types.ts @@ -1,5 +1,112 @@ import type { LoadableData } from '$lib/network/types'; +export type ApiDiffSection = { + id: number; + section_type: 'diff'; + identifier: string; + title?: string; + position?: number; + + diff_sha: string; + base_file_sha: string; + new_file_sha: string; + old_path?: string; + old_size?: number; + new_path?: string; + new_size?: number; + hunks?: number; + lines?: number; + deletions?: number; + diff_patch?: string; +}; + +export type DiffSection = { + id: number; + sectionType: 'diff'; + identifier: string; + title?: string; + position?: number; + + diffSha: string; + baseFileSha: string; + newFileSha: string; + oldPath?: string; + oldSize?: number; + newPath?: string; + newSize?: number; + hunks?: number; + lines?: number; + deletions?: number; + diffPatch?: string; +}; + +export type ApiTextSection = { + id: number; + section_type: 'text'; + identifier: string; + title?: string; + position?: number; + + version?: number; + type?: string; + code?: string; + plain_text?: string; + data?: unknown; +}; + +export type TextSection = { + id: number; + sectionType: 'text'; + identifier: string; + title?: string; + position?: number; + + version?: number; + type?: string; + code?: string; + plainText?: string; + data?: unknown; +}; + +export type ApiSection = ApiDiffSection | ApiTextSection; +export type Section = DiffSection | TextSection; + +export function apiToSection(apiSection: ApiSection): Section { + if (apiSection.section_type === 'diff') { + return { + id: apiSection.id, + sectionType: 'diff', + identifier: apiSection.identifier, + title: apiSection.title, + position: apiSection.position, + diffSha: apiSection.diff_sha, + baseFileSha: apiSection.base_file_sha, + newFileSha: apiSection.new_file_sha, + oldPath: apiSection.old_path, + oldSize: apiSection.old_size, + newPath: apiSection.new_path, + newSize: apiSection.new_size, + hunks: apiSection.hunks, + lines: apiSection.lines, + deletions: apiSection.deletions, + diffPatch: apiSection.diff_patch + }; + } else { + return { + id: apiSection.id, + sectionType: 'text', + identifier: apiSection.identifier, + title: apiSection.title, + position: apiSection.position, + version: apiSection.version, + type: apiSection.type, + code: apiSection.code, + plainText: apiSection.plain_text, + data: apiSection.data + }; + } +} + export type ApiPatchStatistics = { file_count: number; section_count: number; @@ -58,6 +165,7 @@ export type ApiPatch = { statistics: ApiPatchStatistics; review: ApiPatchReview; review_all: ApiPatchReview; + sections?: ApiSection[]; }; export type Patch = { @@ -72,6 +180,7 @@ export type Patch = { statistics: PatchStatistics; review: PatchReview; reviewAll: PatchReview; + sectionIds?: number[]; }; export type LoadablePatch = LoadableData; @@ -87,7 +196,8 @@ export function apiToPatch(api: ApiPatch): Patch { contributors: api.contributors, statistics: apiToPatchStatistics(api.statistics), review: apiToPatchReview(api.review), - reviewAll: apiToPatchReview(api.review_all) + reviewAll: apiToPatchReview(api.review_all), + sectionIds: api.sections?.map((section) => section.id) }; } diff --git a/packages/shared/src/lib/interest/registerInterestFunction.svelte.ts b/packages/shared/src/lib/interest/registerInterestFunction.svelte.ts index 49d61e2431..65a642853e 100644 --- a/packages/shared/src/lib/interest/registerInterestFunction.svelte.ts +++ b/packages/shared/src/lib/interest/registerInterestFunction.svelte.ts @@ -1,6 +1,18 @@ import type { Interest } from '$lib/interest/intrestStore'; -export function registerInterest(interest: Interest) { +export type InView = { + element?: HTMLElement; +}; + +export function registerInterest(interest: Interest, inView?: InView) { + if (inView) { + registerInterestInView(interest, inView.element); + } else { + registerInterestAlways(interest); + } +} + +export function registerInterestAlways(interest: Interest) { $effect(() => { const unsubscribe = interest._subscribe(); diff --git a/packages/shared/src/lib/redux/store.svelte.ts b/packages/shared/src/lib/redux/store.svelte.ts index 60985be93c..b0c4f7394d 100644 --- a/packages/shared/src/lib/redux/store.svelte.ts +++ b/packages/shared/src/lib/redux/store.svelte.ts @@ -1,4 +1,5 @@ import { branchesReducer } from '$lib/branches/branchesSlice'; +import { patchSectionsReducer } from '$lib/branches/patchSectionsSlice'; import { patchesReducer } from '$lib/branches/patchesSlice'; import { feedsReducer } from '$lib/feeds/feedsSlice'; import { postsReducer } from '$lib/feeds/postsSlice'; @@ -44,6 +45,10 @@ export interface AppBranchesState { readonly branches: ReturnType; } +export interface AppPatchSectionsState { + readonly patchSections: ReturnType; +} + export class AppDispatch { constructor(readonly dispatch: typeof AppState.prototype._store.dispatch) {} } @@ -57,7 +62,8 @@ export class AppState AppUsersState, AppProjectsState, AppPatchesState, - AppBranchesState + AppBranchesState, + AppPatchSectionsState { /** * The base store. @@ -74,7 +80,8 @@ export class AppState users: usersReducer, projects: projectsReducer, patches: patchesReducer, - branches: branchesReducer + branches: branchesReducer, + patchSections: patchSectionsReducer } }); @@ -112,6 +119,10 @@ export class AppState [this.selectSelf], (rootState) => rootState.branches ); + private readonly selectPatchSections = createSelector( + [this.selectSelf], + (rootState) => rootState.patchSections + ); readonly example = $derived(this.selectExample(this.rootState)); readonly posts = $derived(this.selectPosts(this.rootState)); @@ -121,6 +132,7 @@ export class AppState readonly projects = $derived(this.selectProjects(this.rootState)); readonly patches = $derived(this.selectPatches(this.rootState)); readonly branches = $derived(this.selectBranches(this.rootState)); + readonly patchSections = $derived(this.selectPatchSections(this.rootState)); constructor() { $effect(() => {