diff --git a/apps/desktop/src/lib/redux/api.ts b/apps/desktop/src/lib/redux/api.ts new file mode 100644 index 0000000000..6bf2d13225 --- /dev/null +++ b/apps/desktop/src/lib/redux/api.ts @@ -0,0 +1,47 @@ +import { Tauri } from '$lib/backend/tauri'; +import { createApi, type BaseQueryApi, type BaseQueryFn } from '@reduxjs/toolkit/query'; + +export const reduxApi = createApi({ + reducerPath: 'api', + tagTypes: ['Stacks'], + baseQuery: tauriBaseQuery, + endpoints: (_) => { + return {}; + } +}); + +function tauriBaseQuery( + args: ApiArgs, + api: BaseQueryApi +): ReturnType, TauriCommandError, object, object>> { + if (!hasTauriExtra(api.extra)) { + return { error: 'Redux dependency Tauri not found!' }; + } + try { + return { data: api.extra.tauri.invoke(args.command, args.params) }; + } catch (error: unknown) { + return { error }; + } +} + +type ApiArgs = { + command: string; + params: Record; +}; + +type TauriCommandError = unknown; + +/** + * Typeguard that makes `tauriBaseQuery` more concise. + */ +function hasTauriExtra(extra: unknown): extra is { + tauri: Tauri; +} { + return ( + !!extra && + typeof extra === 'object' && + extra !== null && + 'tauri' in extra && + extra.tauri instanceof Tauri + ); +} diff --git a/apps/desktop/src/lib/redux/store.svelte.ts b/apps/desktop/src/lib/redux/store.svelte.ts index 5bd75027ae..ed5806e858 100644 --- a/apps/desktop/src/lib/redux/store.svelte.ts +++ b/apps/desktop/src/lib/redux/store.svelte.ts @@ -1,93 +1,39 @@ -import { branchReviewListingsReducer } from '@gitbutler/shared/branches/branchReviewListingsSlice'; -import { branchesReducer } from '@gitbutler/shared/branches/branchesSlice'; -import { latestBranchLookupsReducer } from '@gitbutler/shared/branches/latestBranchLookupSlice'; -import { patchSectionsReducer } from '@gitbutler/shared/branches/patchSectionsSlice'; -import { patchesReducer } from '@gitbutler/shared/branches/patchesSlice'; -import { chatChannelsReducer } from '@gitbutler/shared/chat/chatChannelsSlice'; -import { feedsReducer } from '@gitbutler/shared/feeds/feedsSlice'; -import { postsReducer } from '@gitbutler/shared/feeds/postsSlice'; -import { organizationsReducer } from '@gitbutler/shared/organizations/organizationsSlice'; -import { projectsReducer } from '@gitbutler/shared/organizations/projectsSlice'; -import { repositoryIdLookupsReducer } from '@gitbutler/shared/organizations/repositoryIdLookupsSlice'; -import { exampleReducer } from '@gitbutler/shared/redux/example'; -import { AppDispatch, AppState } from '@gitbutler/shared/redux/store.svelte'; -import { usersReducer } from '@gitbutler/shared/users/usersSlice'; -import { configureStore, createSelector, createSlice } from '@reduxjs/toolkit'; - -type DesktopOnly = { - value: number; -}; - -const desktopOnly = createSlice({ - name: 'desktopOnly', - initialState: { value: 69 } as DesktopOnly, - reducers: { - increment: (state) => { - state.value += 1; - }, - decrement: (state) => { - state.value -= 1; - } - } -}); - -export const { increment: desktopIncrement, decrement: desktopDecrement } = desktopOnly.actions; - -export class DesktopDispatch extends AppDispatch { - constructor(readonly dispatch: typeof DesktopState.prototype._store.dispatch) { - super(dispatch); +import { reduxApi } from './api'; +import { configureStore } from '@reduxjs/toolkit'; +import type { Tauri } from '$lib/backend/tauri'; + +export class DesktopRedux { + readonly store: ReturnType; + readonly dispatch: typeof DesktopRedux.prototype.store.dispatch; + + constructor(readonly tauri: Tauri) { + this.store = createStore(tauri); + this.dispatch = this.store.dispatch; + this.rootState$ = this.store.getState(); + + $effect(() => + this.store.subscribe(() => { + this.rootState$ = this.store.getState(); + }) + ); } -} -interface AppDesktopOnlyState { - readonly desktopOnly: ReturnType; + rootState$ = $state({} as ReturnType); } -// There is some minor duplication in terms of what is declared, but we do get -// type errors if you are missing a base reducer in the configureStore call. -// As such, there shouldn't be any concern about the two getting out of sync. -// This is due to limitations in typescript. -export class DesktopState extends AppState implements AppDesktopOnlyState { - /** - * The base store. - * - * This is a low level API and should not be used directly. - * @private - */ - readonly _store = configureStore({ +/** + * We need this function in order to declare the store type in `DesktopState` + * and then assign the value in the constructor. + */ +function createStore(tauri: Tauri) { + return configureStore({ reducer: { - examples: exampleReducer, - posts: postsReducer, - feeds: feedsReducer, - orgnaizations: organizationsReducer, - users: usersReducer, - projects: projectsReducer, - branches: branchesReducer, - patches: patchesReducer, - patchSections: patchSectionsReducer, - chatChannels: chatChannelsReducer, - repositoryIdLookups: repositoryIdLookupsReducer, - latestBranchLookups: latestBranchLookupsReducer, - branchReviewListings: branchReviewListingsReducer, - desktopOnly: desktopOnly.reducer + api: reduxApi.reducer + }, + middleware: (getDefaultMiddleware) => { + return getDefaultMiddleware({ + thunk: { extraArgument: { tauri } } + }).concat(reduxApi.middleware); } }); - - readonly appDispatch = new DesktopDispatch(this._store.dispatch); - - /** - * Used to access the store directly. It is recommended to access state via - * selectors as they are more efficient. - */ - rootState = $state>(this._store.getState()); - - protected selectSelf(state: ReturnType) { - return state; - } - - private readonly selectDesktopOnly = createSelector( - [this.selectSelf], - (rootState) => rootState.desktopOnly - ); - readonly desktopOnly = $derived(this.selectDesktopOnly(this.rootState)); } diff --git a/apps/desktop/src/lib/stacks/stack.ts b/apps/desktop/src/lib/stacks/stack.ts new file mode 100644 index 0000000000..5209b50a13 --- /dev/null +++ b/apps/desktop/src/lib/stacks/stack.ts @@ -0,0 +1,7 @@ +/** + * Return type of Tauri `stacks` command. + */ +export type Stack = { + id: string; + branchNames: string[]; +}; diff --git a/apps/desktop/src/lib/stacks/stackService.svelte.ts b/apps/desktop/src/lib/stacks/stackService.svelte.ts new file mode 100644 index 0000000000..0c1e178507 --- /dev/null +++ b/apps/desktop/src/lib/stacks/stackService.svelte.ts @@ -0,0 +1,48 @@ +import { reduxApi } from '$lib/redux/api'; +import { DesktopRedux } from '$lib/redux/store.svelte'; +import type { Stack } from './stack'; + +enum Tags { + Stacks = 'Stacks' +} + +export class StackService { + private stacksApi = reduxApi.injectEndpoints({ + endpoints: (build) => ({ + /** Fetches all stacks. */ + get: build.query({ + query: ({ projectId }) => ({ command: 'stacks', params: { projectId } }), + providesTags: [Tags.Stacks] + }), + new: build.mutation({ + query: ({ projectId }) => ({ + command: 'create_virtual_branch', + params: { projectId, branch: {} } + }), + invalidatesTags: [Tags.Stacks] + }) + }) + }); + + constructor(private state: DesktopRedux) {} + + poll(projectId: string) { + $effect(() => { + const { unsubscribe } = this.state.dispatch( + this.stacksApi.endpoints.get.initiate({ projectId }) + ); + return () => { + unsubscribe(); + }; + }); + } + + select(projectId: string) { + return this.stacksApi.endpoints.get.select({ projectId }); + } + + // eslint-disable-next-line @typescript-eslint/promise-function-async + new(projectId: string) { + return this.state.dispatch(this.stacksApi.endpoints.new.initiate({ projectId })); + } +} diff --git a/apps/desktop/src/routes/+layout.svelte b/apps/desktop/src/routes/+layout.svelte index 24c1f1085a..38350cb191 100644 --- a/apps/desktop/src/routes/+layout.svelte +++ b/apps/desktop/src/routes/+layout.svelte @@ -33,7 +33,7 @@ import { platformName } from '$lib/platform/platform'; import { ProjectsService } from '$lib/project/projectsService'; import { PromptService } from '$lib/prompt/promptService'; - import { DesktopDispatch, DesktopState } from '$lib/redux/store.svelte'; + import { DesktopRedux } from '$lib/redux/store.svelte'; import { RemotesService } from '$lib/remotes/remotesService'; import { DesktopRoutesService } from '$lib/routes/routes.svelte'; import { setSecretsService } from '$lib/secrets/secretsService'; @@ -71,7 +71,8 @@ const userSettings = loadUserSettings(); setContext(SETTINGS, userSettings); - const appState = new DesktopState(); + const appState = new AppState(); + const desktopState = new DesktopRedux(data.tauri); const feedService = new FeedService(data.cloud, appState.appDispatch); const organizationService = new OrganizationService(data.cloud, appState.appDispatch); const cloudUserService = new CloudUserService(data.cloud, appState.appDispatch); @@ -85,8 +86,7 @@ setContext(AppState, appState); setContext(AppDispatch, appState.appDispatch); - setContext(DesktopState, appState); - setContext(DesktopDispatch, appState.appDispatch); + setContext(DesktopRedux, desktopState); setContext(FeedService, feedService); setContext(OrganizationService, organizationService); setContext(CloudUserService, cloudUserService); diff --git a/apps/desktop/src/routes/reduxExample/+page.svelte b/apps/desktop/src/routes/reduxExample/+page.svelte deleted file mode 100644 index 0e0b1a4e71..0000000000 --- a/apps/desktop/src/routes/reduxExample/+page.svelte +++ /dev/null @@ -1,73 +0,0 @@ - - -
- - -

Redux Example

-

Current value: {currentValue}

- -
- - -
-
-

- Is current value greater than ? {greaterThanComparisonTarget} -

- -
- -

Redux Desktop Only Example

-

Current value: {currentDesktopValue}

- -
- - -
-
-
- -