diff --git a/apps/desktop/cypress.config.ts b/apps/desktop/cypress.config.ts
index b6e68a4c5e..9b0e4076bf 100644
--- a/apps/desktop/cypress.config.ts
+++ b/apps/desktop/cypress.config.ts
@@ -1,4 +1,6 @@
+import { svelte } from '@sveltejs/vite-plugin-svelte';
import { defineConfig } from 'cypress';
+import path from 'path';
export default defineConfig({
retries: {
@@ -7,9 +9,29 @@ export default defineConfig({
// Configure retry attempts for `cypress open`
openMode: 0
},
+
e2e: {
baseUrl: 'http://localhost:1420',
supportFile: 'cypress/e2e/support/index.ts'
},
- experimentalWebKitSupport: true
+
+ experimentalWebKitSupport: true,
+
+ component: {
+ devServer: {
+ framework: 'svelte',
+ bundler: 'vite',
+ viteConfig: {
+ plugins: [svelte()],
+ resolve: {
+ alias: {
+ $components: path.resolve('src/components'),
+ $lib: path.resolve('src/lib')
+ }
+ }
+ }
+ },
+ // 👇 And this line if Cypress still fails to resolve the iframe mount file
+ indexHtmlFile: 'cypress/support/index.html'
+ }
});
diff --git a/apps/desktop/cypress/component/MesageEditor.cy.ts b/apps/desktop/cypress/component/MesageEditor.cy.ts
new file mode 100644
index 0000000000..80f2a4195b
--- /dev/null
+++ b/apps/desktop/cypress/component/MesageEditor.cy.ts
@@ -0,0 +1,38 @@
+import MessageEditor from '$components/v3/editor/MessageEditor.svelte';
+import { SETTINGS, type Settings } from '$lib/settings/userSettings';
+import { UiState } from '$lib/state/uiState.svelte';
+import { TestId } from '$lib/testing/testIds';
+import { HttpClient } from '@gitbutler/shared/network/httpClient';
+import { UploadsService } from '@gitbutler/shared/uploads/uploadsService';
+import { readable, writable } from 'svelte/store';
+import '../../src/styles/styles.css';
+import '@gitbutler/ui/main.css';
+
+describe('CommitMesageEditor.cy.ts', () => {
+ const httpClient = new HttpClient(window.fetch, 'https://www.example.com', writable(''));
+ const settings = writable({} as Settings);
+ it('playground', () => {
+ const context = new Map();
+ const uiState = new UiState(readable({ ids: [], entities: {} }), () => {});
+ context.set(UiState, uiState);
+ context.set(UploadsService, new UploadsService(httpClient));
+ context.set(SETTINGS, settings);
+
+ const mountResult = cy.mount(MessageEditor, {
+ props: {
+ projectId: '1234',
+ initialValue: 'Hello world!',
+ placeholder: 'text goes here',
+ testId: TestId.EditCommitMessageBox
+ } as const,
+ context
+ });
+ mountResult
+ .then(async ({ component }) => {
+ const comp = component as MessageEditor;
+ return await comp.getPlaintext();
+ })
+ .should('eq', 'Hello world!');
+ cy.getByTestId(TestId.EditCommitMessageBox).should('exist').click().type('new text!');
+ });
+});
diff --git a/apps/desktop/cypress/support/commands.ts b/apps/desktop/cypress/support/commands.ts
new file mode 100644
index 0000000000..26ff8270bd
--- /dev/null
+++ b/apps/desktop/cypress/support/commands.ts
@@ -0,0 +1,49 @@
+///
+
+import type { TestId } from '$lib/testing/testIds';
+
+// ***********************************************
+// This example commands.ts shows you how to
+// create various custom commands and overwrite
+// existing commands.
+//
+// For more comprehensive examples of custom
+// commands please read more here:
+// https://on.cypress.io/custom-commands
+// ***********************************************
+//
+//
+// -- This is a parent command --
+// Cypress.Commands.add('login', (email, password) => { ... })
+//
+//
+// -- This is a child command --
+// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
+//
+//
+// -- This is a dual command --
+// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
+//
+//
+// -- This will overwrite an existing command --
+// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
+//
+// declare global {
+// namespace Cypress {
+// interface Chainable {
+// login(email: string, password: string): Chainable
+// drag(subject: string, options?: Partial): Chainable
+// dismiss(subject: string, options?: Partial): Chainable
+// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable
+// }
+// }
+// }
+
+type TestIdValues = `${TestId}`;
+
+Cypress.Commands.add('getByTestId', (testId: TestIdValues, containingText?: string) => {
+ if (containingText) {
+ return cy.contains(`[data-testid="${testId}"]`, containingText, { timeout: 15000 });
+ }
+ return cy.get(`[data-testid="${testId}"]`, { timeout: 15000 });
+});
diff --git a/apps/desktop/cypress/support/component.ts b/apps/desktop/cypress/support/component.ts
new file mode 100644
index 0000000000..0230a656ef
--- /dev/null
+++ b/apps/desktop/cypress/support/component.ts
@@ -0,0 +1,36 @@
+// ***********************************************************
+// This example support/component.ts is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands';
+
+import { mount } from 'cypress/svelte';
+
+// Augment the Cypress namespace to include type definitions for
+// your custom command.
+// Alternatively, can be defined in cypress/support/component.d.ts
+// with a at the top of your spec.
+declare global {
+ namespace Cypress {
+ interface Chainable {
+ mount: typeof mount;
+ }
+ }
+}
+
+Cypress.Commands.add('mount', mount);
+
+// Example use:
+// cy.mount(MyComponent)
diff --git a/apps/desktop/cypress/support/index.html b/apps/desktop/cypress/support/index.html
new file mode 100644
index 0000000000..ac6e79fd83
--- /dev/null
+++ b/apps/desktop/cypress/support/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ Components App
+
+
+
+
+
\ No newline at end of file
diff --git a/apps/desktop/cypress/tsconfig.json b/apps/desktop/cypress/tsconfig.json
index 43681fb925..b8cdcdcbd3 100644
--- a/apps/desktop/cypress/tsconfig.json
+++ b/apps/desktop/cypress/tsconfig.json
@@ -3,5 +3,5 @@
"compilerOptions": {
"types": ["cypress"]
},
- "include": ["e2e/**/*.ts"]
+ "include": ["**/*.ts"]
}
diff --git a/apps/desktop/src/components/v3/editor/MessageEditor.svelte b/apps/desktop/src/components/v3/editor/MessageEditor.svelte
index 3db67dce98..a804d7efe2 100644
--- a/apps/desktop/src/components/v3/editor/MessageEditor.svelte
+++ b/apps/desktop/src/components/v3/editor/MessageEditor.svelte
@@ -15,6 +15,7 @@
import { showError } from '$lib/notifications/toasts';
import { SETTINGS, type Settings } from '$lib/settings/userSettings';
import { UiState } from '$lib/state/uiState.svelte';
+ import { TestId } from '$lib/testing/testIds';
import { getContext, getContextStoreBySymbol } from '@gitbutler/shared/context';
import { uploadFiles } from '@gitbutler/shared/dom';
import { persisted } from '@gitbutler/shared/persisted';
@@ -54,9 +55,9 @@
enableSmiles?: boolean;
enableRichText?: boolean;
enableRuler?: boolean;
- onAiButtonClick: (params: AiButtonClickParams) => void;
- canUseAI: boolean;
- aiIsLoading: boolean;
+ onAiButtonClick?: (params: AiButtonClickParams) => void;
+ canUseAI?: boolean;
+ aiIsLoading?: boolean;
suggestionsHandler?: CommitSuggestions;
testId?: string;
}
@@ -219,7 +220,7 @@
function handleGenerateMessage() {
if (aiIsLoading) return;
- onAiButtonClick({
+ onAiButtonClick?.({
useEmojiStyle: $commitGenerationUseEmojis,
useBriefStyle: $commitGenerationExtraConcise
});
@@ -385,6 +386,7 @@
onclick={() => {
useFloatingBox.current = !useFloatingBox.current;
}}
+ testId={TestId.FloatingModeButton}
/>
{#if enableSmiles}
diff --git a/apps/desktop/src/lib/analytics/analytics.ts b/apps/desktop/src/lib/analytics/analytics.ts
index 3f838f1cbb..1e4c1bbcc5 100644
--- a/apps/desktop/src/lib/analytics/analytics.ts
+++ b/apps/desktop/src/lib/analytics/analytics.ts
@@ -3,6 +3,7 @@ import { initSentry } from '$lib/analytics/sentry';
import { AppSettings } from '$lib/config/appSettings';
import { getName, getVersion } from '@tauri-apps/api/app';
import posthog from 'posthog-js';
+import { PUBLIC_POSTHOG_API_KEY } from '$env/static/public';
export function initAnalyticsIfEnabled(appSettings: AppSettings, postHog: PostHogWrapper) {
if (import.meta.env.MODE === 'development') return;
@@ -15,7 +16,7 @@ export function initAnalyticsIfEnabled(appSettings: AppSettings, postHog: PostHo
appSettings.appMetricsEnabled.onDisk().then(async (enabled) => {
if (enabled) {
const [appName, appVersion] = await Promise.all([getName(), getVersion()]);
- postHog.init(appName, appVersion);
+ postHog.init(appName, appVersion, PUBLIC_POSTHOG_API_KEY);
}
});
appSettings.appNonAnonMetricsEnabled.onDisk().then((enabled) => {
diff --git a/apps/desktop/src/lib/analytics/posthog.ts b/apps/desktop/src/lib/analytics/posthog.ts
index 105fd1dda4..726c4cd2ec 100644
--- a/apps/desktop/src/lib/analytics/posthog.ts
+++ b/apps/desktop/src/lib/analytics/posthog.ts
@@ -2,7 +2,6 @@ import { PostHog, posthog, type Properties } from 'posthog-js';
import type { EventContext } from '$lib/analytics/eventContext';
import type { SettingsService } from '$lib/config/appSettingsV2';
import type { RepoInfo } from '$lib/url/gitUrl';
-import { PUBLIC_POSTHOG_API_KEY } from '$env/static/public';
export class PostHogWrapper {
private _instance: PostHog | void = undefined;
@@ -18,8 +17,8 @@ export class PostHogWrapper {
this._instance?.capture(eventName, newProperties);
}
- async init(appName: string, appVersion: string) {
- this._instance = posthog.init(PUBLIC_POSTHOG_API_KEY, {
+ async init(appName: string, appVersion: string, apiKey: string) {
+ this._instance = posthog.init(apiKey, {
api_host: 'https://eu.posthog.com',
autocapture: false,
disable_session_recording: true,
diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts
index df857c60c2..9ad12a9e81 100644
--- a/apps/desktop/src/lib/backend/tauri.ts
+++ b/apps/desktop/src/lib/backend/tauri.ts
@@ -2,6 +2,8 @@ import { invoke as invokeIpc, listen as listenIpc } from '$lib/backend/ipc';
import { getVersion } from '@tauri-apps/api/app';
import { check } from '@tauri-apps/plugin-updater';
+export const IS_TAURI_ENV = '__TAURI_INTERNALS__' in window;
+
export class Tauri {
invoke = invokeIpc;
listen = listenIpc;
diff --git a/apps/desktop/src/lib/irc/ircService.svelte.ts b/apps/desktop/src/lib/irc/ircService.svelte.ts
index 3c01e6ebd4..9638596314 100644
--- a/apps/desktop/src/lib/irc/ircService.svelte.ts
+++ b/apps/desktop/src/lib/irc/ircService.svelte.ts
@@ -25,7 +25,7 @@ import persistReducer from 'redux-persist/es/persistReducer';
import storage from 'redux-persist/lib/storage';
import type { IrcClient } from '$lib/irc/ircClient.svelte';
import type { IrcEvent } from '$lib/irc/parser';
-import type { IrcChannel, IrcChat, WhoInfo } from '$lib/irc/types';
+import type { IrcChannel, IrcChat, IRCState, WhoInfo } from '$lib/irc/types';
import type { ClientState } from '$lib/state/clientState.svelte';
import type { Reactive } from '@gitbutler/shared/storeUtils';
import type { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit';
@@ -49,32 +49,25 @@ export class IrcService {
};
clientState.inject(ircSlice.reducerPath, persistReducer(persistConfig, ircSlice.reducer));
+ const store = clientState.rootState;
- $effect(() => {
- if (clientState.reactiveState) {
- if (ircSlice.reducerPath in clientState.reactiveState) {
- // @ts-expect-error code-splitting means it's not defined in client state.
- this.state = clientState.reactiveState[ircSlice.reducerPath] as IRCState;
- }
- }
+ store.subscribe((value) => {
+ // @ts-expect-error code-splitting means it's not defined in client state.
+ this.state = value[ircSlice.reducerPath] as IRCState;
});
- $effect(() => {
- return this.ircClient.onevent(async (event) => {
- return this.handleEvent(event);
- });
+ this.ircClient.onevent(async (event) => {
+ return this.handleEvent(event);
});
- $effect(() => {
- return this.ircClient.onopen(() => {
- const channels = this.getChannels();
- this.dispatch(clearNames());
- setTimeout(() => {
- for (const channel of channels.current) {
- this.send(`JOIN ${channel?.name}`);
- }
- }, 5000);
- });
+ this.ircClient.onopen(() => {
+ const channels = this.getChannels();
+ this.dispatch(clearNames());
+ setTimeout(() => {
+ for (const channel of channels.current) {
+ this.send(`JOIN ${channel?.name}`);
+ }
+ }, 5000);
});
}
diff --git a/apps/desktop/src/lib/platform/platform.ts b/apps/desktop/src/lib/platform/platform.ts
index 8d52f72c9f..c6f9cf1d4b 100644
--- a/apps/desktop/src/lib/platform/platform.ts
+++ b/apps/desktop/src/lib/platform/platform.ts
@@ -1,3 +1,4 @@
+import { IS_TAURI_ENV } from '$lib/backend/tauri';
import { platform } from '@tauri-apps/plugin-os';
-export const platformName = platform();
+export const platformName = IS_TAURI_ENV ? platform() : undefined;
diff --git a/apps/desktop/src/lib/selection/uncommittedService.svelte.ts b/apps/desktop/src/lib/selection/uncommittedService.svelte.ts
index 8af2803b06..49977d131b 100644
--- a/apps/desktop/src/lib/selection/uncommittedService.svelte.ts
+++ b/apps/desktop/src/lib/selection/uncommittedService.svelte.ts
@@ -47,10 +47,10 @@ export class UncommittedService {
persistReducer(persistConfig, uncommittedSlice.reducer)
);
- $effect(() => {
- if (clientState.reactiveState && uncommittedSlice.reducerPath in clientState.reactiveState) {
+ clientState.rootState.subscribe((value) => {
+ if (value && uncommittedSlice.reducerPath in value) {
// @ts-expect-error code-splitting means it's not defined in client state.
- this.state = clientState.reactiveState[uncommittedSlice.reducerPath];
+ this.state = value[uncommittedSlice.reducerPath];
}
});
}
diff --git a/apps/desktop/src/lib/state/clientState.svelte.ts b/apps/desktop/src/lib/state/clientState.svelte.ts
index a283591702..c582646510 100644
--- a/apps/desktop/src/lib/state/clientState.svelte.ts
+++ b/apps/desktop/src/lib/state/clientState.svelte.ts
@@ -2,12 +2,12 @@ import { tauriBaseQuery } from '$lib/state/backendQuery';
import { butlerModule } from '$lib/state/butlerModule';
import { ReduxTag } from '$lib/state/tags';
import { uiStateSlice } from '$lib/state/uiState.svelte';
-import { mergeUnlisten } from '@gitbutler/ui/utils/mergeUnlisten';
import { combineSlices, configureStore, type Reducer } from '@reduxjs/toolkit';
import { buildCreateApi, coreModule, setupListeners, type RootState } from '@reduxjs/toolkit/query';
import { FLUSH, PAUSE, PERSIST, persistReducer, PURGE, REGISTER, REHYDRATE } from 'redux-persist';
import persistStore from 'redux-persist/lib/persistStore';
import storage from 'redux-persist/lib/storage';
+import { derived, writable, type Readable } from 'svelte/store';
import type { PostHogWrapper } from '$lib/analytics/posthog';
import type { Tauri } from '$lib/backend/tauri';
import type { GitHubClient } from '$lib/forge/github/githubClient';
@@ -43,8 +43,8 @@ export class ClientState {
// $state requires field declaration, but we have to assign the initial
// value in the constructor such that we can inject dependencies. The
// incorrect casting `as` seems difficult to avoid.
- rootState = $state.raw({} as ReturnType);
- readonly uiState = $derived(this.rootState.uiState);
+ readonly rootState = writable({} as ReturnType);
+ readonly uiState = derived(this.rootState, (value) => value.uiState);
/** rtk-query api for communicating with the back end. */
readonly backendApi: BackendApi;
@@ -55,10 +55,6 @@ export class ClientState {
/** rtk-query api for communicating with GitLab. */
readonly gitlabApi: GitLabApi;
- get reactiveState() {
- return this.rootState;
- }
-
constructor(
tauri: Tauri,
gitHubClient: GitHubClient,
@@ -69,7 +65,7 @@ export class ClientState {
const butlerMod = butlerModule({
// Reactive loop without nested function.
// TODO: Can it be done without nesting?
- getState: () => () => this.rootState as any as RootState,
+ store: this.rootState as any as Readable>,
getDispatch: () => this.dispatch,
posthog
});
@@ -91,16 +87,13 @@ export class ClientState {
this.reducer = reducer;
setupListeners(this.store.dispatch);
this.dispatch = this.store.dispatch;
- this.rootState = this.store.getState();
-
- $effect(() =>
- mergeUnlisten(
- this.store.subscribe(() => {
- this.rootState = this.store.getState();
- }),
- setupListeners(this.store.dispatch)
- )
- );
+ this.rootState.set(this.store.getState());
+
+ this.store.subscribe(() => {
+ this.rootState.set(this.store.getState());
+ });
+
+ setupListeners(this.store.dispatch);
}
inject(reducerPath: string, reducer: Reducer) {
diff --git a/apps/desktop/src/lib/state/context.ts b/apps/desktop/src/lib/state/context.ts
index 04387c62b5..8d504006bd 100644
--- a/apps/desktop/src/lib/state/context.ts
+++ b/apps/desktop/src/lib/state/context.ts
@@ -1,6 +1,7 @@
import type { PostHogWrapper } from '$lib/analytics/posthog';
import type { EntityState, ThunkDispatch, UnknownAction } from '@reduxjs/toolkit';
import type { CombinedState } from '@reduxjs/toolkit/query';
+import type { Readable } from 'svelte/store';
/**
* The api is necessary to create the store, so we need to provide
@@ -11,7 +12,7 @@ import type { CombinedState } from '@reduxjs/toolkit/query';
*/
export type HookContext = {
/** Without the nested function we get looping reactivity. */
- getState: () => () => { [k: string]: CombinedState | EntityState };
+ store: Readable<{ [k: string]: CombinedState | EntityState }>;
getDispatch: () => ThunkDispatch;
posthog?: PostHogWrapper;
};
diff --git a/apps/desktop/src/lib/state/customHooks.svelte.ts b/apps/desktop/src/lib/state/customHooks.svelte.ts
index cb0a135c4a..bf50913064 100644
--- a/apps/desktop/src/lib/state/customHooks.svelte.ts
+++ b/apps/desktop/src/lib/state/customHooks.svelte.ts
@@ -37,14 +37,18 @@ const EVENT_NAME = 'tauri_command';
export function buildQueryHooks({
api,
endpointName,
- ctx: { getState, getDispatch }
+ ctx: { store, getDispatch }
}: {
api: Api;
endpointName: string;
ctx: HookContext;
}) {
const endpoint = api.endpoints[endpointName]!;
- const state = getState() as any as () => RootState;
+ let state = $state.raw>({});
+
+ store.subscribe((value) => {
+ state = value as RootState;
+ });
const { initiate, select } = endpoint as ApiEndpointQuery, Definitions>;
@@ -94,7 +98,7 @@ export function buildQueryHooks({
}
const selector = $derived(select(queryArg));
- const result = $derived(selector(state()));
+ const result = $derived(selector(state));
const output = $derived.by(() => {
let data = result.data;
if (options?.transform && data) {
@@ -133,7 +137,7 @@ export function buildQueryHooks({
const results = queryArgs.map((queryArg) => {
const selector = $derived(select(queryArg));
- const result = $derived(selector(state()));
+ const result = $derived(selector(state));
const output = $derived.by(() => {
let data = result.data;
if (options?.transform && data) {
@@ -151,7 +155,7 @@ export function buildQueryHooks({
function useQueryState(queryArg: unknown, options?: { transform?: T }) {
const selector = $derived(select(queryArg));
- const result = $derived(selector(state()));
+ const result = $derived(selector(state));
const output = $derived.by(() => {
let data = result.data;
if (options?.transform && data) {
@@ -167,7 +171,7 @@ export function buildQueryHooks({
function useQueryTimeStamp(queryArg: unknown) {
const selector = $derived(select(queryArg));
- const result = $derived(selector(state()));
+ const result = $derived(selector(state));
return reactive(() => result.startedTimeStamp);
}
@@ -283,7 +287,7 @@ export function buildMutationHook<
endpointName,
actionName,
command,
- ctx: { getState, getDispatch, posthog }
+ ctx: { store: getState, getDispatch, posthog }
}: {
api: Api;
endpointName: string;
@@ -292,7 +296,10 @@ export function buildMutationHook<
ctx: HookContext;
}): MutationHook {
const endpoint = api.endpoints[endpointName]!;
- const state = getState() as any as () => RootState;
+ let state = $state.raw>({});
+ getState.subscribe((value) => {
+ state = value as RootState;
+ });
const { initiate, select } = endpoint as unknown as ApiEndpointMutation;
@@ -398,7 +405,7 @@ export function buildMutationHook<
}
const selector = $derived(select({ requestId: promise?.requestId, fixedCacheKey }));
- const result = $derived(selector(state()));
+ const result = $derived(selector(state));
$effect(() => {
return () => {
diff --git a/apps/desktop/src/lib/state/uiState.svelte.ts b/apps/desktop/src/lib/state/uiState.svelte.ts
index aeadf36b48..10170fde52 100644
--- a/apps/desktop/src/lib/state/uiState.svelte.ts
+++ b/apps/desktop/src/lib/state/uiState.svelte.ts
@@ -9,6 +9,7 @@ import {
type UnknownAction
} from '@reduxjs/toolkit';
import type { RejectionReason } from '$lib/stacks/stackService.svelte';
+import type { Readable } from 'svelte/store';
export type StackSelection = {
branchName: string;
@@ -138,11 +139,11 @@ export class UiState {
});
constructor(
- reactiveState: Reactive,
+ store: Readable,
private dispatch: ThunkDispatch
) {
- $effect(() => {
- this.state = reactiveState.current;
+ store.subscribe((value) => {
+ this.state = value;
});
}
diff --git a/apps/desktop/src/lib/testing/mockGitHubApi.svelte.ts b/apps/desktop/src/lib/testing/mockGitHubApi.svelte.ts
index 7d65c3068c..c42dba22c5 100644
--- a/apps/desktop/src/lib/testing/mockGitHubApi.svelte.ts
+++ b/apps/desktop/src/lib/testing/mockGitHubApi.svelte.ts
@@ -4,6 +4,7 @@ import { butlerModule } from '$lib/state/butlerModule';
import { createGitHubApi } from '$lib/state/clientState.svelte';
import { Octokit } from '@octokit/rest';
import { configureStore, type ThunkDispatch, type UnknownAction } from '@reduxjs/toolkit';
+import { readable } from 'svelte/store';
/**
* Mock for GitHub RTKQ.
@@ -31,7 +32,7 @@ export function setupMockGitHubApi() {
const gitHubClient = new GitHubClient({ client: octokit });
gitHubClient.setRepo({ owner: 'test-owner', repo: 'test-repo' });
const gitHubApi = createGitHubApi(
- butlerModule({ getDispatch: () => dispatch!, getState: () => () => state })
+ butlerModule({ getDispatch: () => dispatch!, store: readable(state) })
);
const store = configureStore({
diff --git a/apps/desktop/src/lib/testing/testIds.ts b/apps/desktop/src/lib/testing/testIds.ts
index 552dd0a128..f31ccbb037 100644
--- a/apps/desktop/src/lib/testing/testIds.ts
+++ b/apps/desktop/src/lib/testing/testIds.ts
@@ -104,7 +104,8 @@ export enum TestId {
StackSelectionView = 'stack-selection-view',
BranchesSelectionView = 'branches-selection-view',
WorkspaceSelectionView = 'workspace-selection-view',
- ProjectNotFoundPage = 'project-not-found-page'
+ ProjectNotFoundPage = 'project-not-found-page',
+ FloatingModeButton = 'floating-mode-button'
}
export enum ElementId {
diff --git a/apps/desktop/src/lib/utils/theme.ts b/apps/desktop/src/lib/utils/theme.ts
index 45bfe297ff..2518bb38f9 100644
--- a/apps/desktop/src/lib/utils/theme.ts
+++ b/apps/desktop/src/lib/utils/theme.ts
@@ -1,17 +1,19 @@
+import { IS_TAURI_ENV } from '$lib/backend/tauri';
import { getCurrentWindow, type Theme } from '@tauri-apps/api/window';
import { type Writable } from 'svelte/store';
import type { Settings } from '$lib/settings/userSettings';
-const appWindow = getCurrentWindow();
+
+const appWindow = IS_TAURI_ENV ? getCurrentWindow() : undefined;
let systemTheme: string | null;
let selectedTheme: string | undefined;
export function initTheme(userSettings: Writable) {
- appWindow.theme().then((value: Theme | null) => {
+ appWindow?.theme().then((value: Theme | null) => {
systemTheme = value;
updateDom();
});
- appWindow.onThemeChanged((e) => {
+ appWindow?.onThemeChanged((e) => {
systemTheme = e.payload;
updateDom();
});
diff --git a/apps/desktop/src/routes/+layout.svelte b/apps/desktop/src/routes/+layout.svelte
index 30317757b5..c083eca57c 100644
--- a/apps/desktop/src/routes/+layout.svelte
+++ b/apps/desktop/src/routes/+layout.svelte
@@ -78,7 +78,6 @@
import { ProjectService as CloudProjectService } from '@gitbutler/shared/organizations/projectService';
import { RepositoryIdLookupService } from '@gitbutler/shared/organizations/repositoryIdLookupService';
import { PatchCommitService as CloudPatchCommitService } from '@gitbutler/shared/patches/patchCommitService';
- import { reactive } from '@gitbutler/shared/reactiveUtils.svelte';
import { AppDispatch, AppState } from '@gitbutler/shared/redux/store.svelte';
import { WebRoutesService } from '@gitbutler/shared/routing/webRoutes.svelte';
import { UploadsService } from '@gitbutler/shared/uploads/uploadsService';
@@ -142,11 +141,7 @@
projectMetrics: data.projectMetrics
});
- const uiStateSlice = $derived(clientState.uiState);
- const uiState = new UiState(
- reactive(() => uiStateSlice),
- clientState.dispatch
- );
+ const uiState = new UiState(clientState.uiState, clientState.dispatch);
setContext(UiState, uiState);
const intelligentScrollingService = new IntelligentScrollingService(uiState);
setContext(IntelligentScrollingService, intelligentScrollingService);
diff --git a/apps/desktop/src/routes/+layout.ts b/apps/desktop/src/routes/+layout.ts
index 0c93eeae98..d8da80491e 100644
--- a/apps/desktop/src/routes/+layout.ts
+++ b/apps/desktop/src/routes/+layout.ts
@@ -4,7 +4,7 @@ import { initAnalyticsIfEnabled } from '$lib/analytics/analytics';
import { EventContext } from '$lib/analytics/eventContext';
import { PostHogWrapper } from '$lib/analytics/posthog';
import { CommandService } from '$lib/backend/ipc';
-import { Tauri } from '$lib/backend/tauri';
+import { IS_TAURI_ENV, Tauri } from '$lib/backend/tauri';
import { loadAppSettings } from '$lib/config/appSettings';
import { SettingsService } from '$lib/config/appSettingsV2';
import { GitConfigService } from '$lib/config/gitConfigService';
@@ -37,7 +37,9 @@ export const csr = true;
export const load: LayoutLoad = async () => {
// TODO: Find a workaround to avoid this dynamic import
// https://github.com/sveltejs/kit/issues/905
- const defaultPath = await (await import('@tauri-apps/api/path')).homeDir();
+ const defaultPath = IS_TAURI_ENV
+ ? await (await import('@tauri-apps/api/path')).homeDir()
+ : undefined;
const commandService = new CommandService();
diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json
index b77455bae3..37cae037a8 100644
--- a/apps/desktop/tsconfig.json
+++ b/apps/desktop/tsconfig.json
@@ -30,6 +30,5 @@
"src/**/*.ts",
"src/**/*.svelte",
"cypress.config.ts",
- "cypress/**/*.ts"
]
}
diff --git a/packages/ui/src/styles/utility/layout.min.css b/packages/ui/src/styles/utility/layout.min.css
index 65da54c768..2a6c487429 100644
--- a/packages/ui/src/styles/utility/layout.min.css
+++ b/packages/ui/src/styles/utility/layout.min.css
@@ -1,11 +1,566 @@
/* Spasing */
-.gap-2{gap:2px}.p-2{padding:2px}.p-left-2{padding-left:2px}.p-right-2{padding-right:2px}.p-top-2{padding-top:2px}.p-bottom-2{padding-bottom:2px}.m-2{margin:2px}.m-left-2{margin-left:2px}.m-right-2{margin-right:2px}.m-top-2{margin-top:2px}.m-bottom-2{margin-bottom:2px}.gap-4{gap:4px}.p-4{padding:4px}.p-left-4{padding-left:4px}.p-right-4{padding-right:4px}.p-top-4{padding-top:4px}.p-bottom-4{padding-bottom:4px}.m-4{margin:4px}.m-left-4{margin-left:4px}.m-right-4{margin-right:4px}.m-top-4{margin-top:4px}.m-bottom-4{margin-bottom:4px}.gap-6{gap:6px}.p-6{padding:6px}.p-left-6{padding-left:6px}.p-right-6{padding-right:6px}.p-top-6{padding-top:6px}.p-bottom-6{padding-bottom:6px}.m-6{margin:6px}.m-left-6{margin-left:6px}.m-right-6{margin-right:6px}.m-top-6{margin-top:6px}.m-bottom-6{margin-bottom:6px}.gap-8{gap:8px}.p-8{padding:8px}.p-left-8{padding-left:8px}.p-right-8{padding-right:8px}.p-top-8{padding-top:8px}.p-bottom-8{padding-bottom:8px}.m-8{margin:8px}.m-left-8{margin-left:8px}.m-right-8{margin-right:8px}.m-top-8{margin-top:8px}.m-bottom-8{margin-bottom:8px}.gap-10{gap:10px}.p-10{padding:10px}.p-left-10{padding-left:10px}.p-right-10{padding-right:10px}.p-top-10{padding-top:10px}.p-bottom-10{padding-bottom:10px}.m-10{margin:10px}.m-left-10{margin-left:10px}.m-right-10{margin-right:10px}.m-top-10{margin-top:10px}.m-bottom-10{margin-bottom:10px}.gap-12{gap:12px}.p-12{padding:12px}.p-left-12{padding-left:12px}.p-right-12{padding-right:12px}.p-top-12{padding-top:12px}.p-bottom-12{padding-bottom:12px}.m-12{margin:12px}.m-left-12{margin-left:12px}.m-right-12{margin-right:12px}.m-top-12{margin-top:12px}.m-bottom-12{margin-bottom:12px}.gap-14{gap:14px}.p-14{padding:14px}.p-left-14{padding-left:14px}.p-right-14{padding-right:14px}.p-top-14{padding-top:14px}.p-bottom-14{padding-bottom:14px}.m-14{margin:14px}.m-left-14{margin-left:14px}.m-right-14{margin-right:14px}.m-top-14{margin-top:14px}.m-bottom-14{margin-bottom:14px}.gap-16{gap:16px}.p-16{padding:16px}.p-left-16{padding-left:16px}.p-right-16{padding-right:16px}.p-top-16{padding-top:16px}.p-bottom-16{padding-bottom:16px}.m-16{margin:16px}.m-left-16{margin-left:16px}.m-right-16{margin-right:16px}.m-top-16{margin-top:16px}.m-bottom-16{margin-bottom:16px}.gap-20{gap:20px}.p-20{padding:20px}.p-left-20{padding-left:20px}.p-right-20{padding-right:20px}.p-top-20{padding-top:20px}.p-bottom-20{padding-bottom:20px}.m-20{margin:20px}.m-left-20{margin-left:20px}.m-right-20{margin-right:20px}.m-top-20{margin-top:20px}.m-bottom-20{margin-bottom:20px}.gap-24{gap:24px}.p-24{padding:24px}.p-left-24{padding-left:24px}.p-right-24{padding-right:24px}.p-top-24{padding-top:24px}.p-bottom-24{padding-bottom:24px}.m-24{margin:24px}.m-left-24{margin-left:24px}.m-right-24{margin-right:24px}.m-top-24{margin-top:24px}.m-bottom-24{margin-bottom:24px}.gap-28{gap:28px}.p-28{padding:28px}.p-left-28{padding-left:28px}.p-right-28{padding-right:28px}.p-top-28{padding-top:28px}.p-bottom-28{padding-bottom:28px}.m-28{margin:28px}.m-left-28{margin-left:28px}.m-right-28{margin-right:28px}.m-top-28{margin-top:28px}.m-bottom-28{margin-bottom:28px}.gap-32{gap:32px}.p-32{padding:32px}.p-left-32{padding-left:32px}.p-right-32{padding-right:32px}.p-top-32{padding-top:32px}.p-bottom-32{padding-bottom:32px}.m-32{margin:32px}.m-left-32{margin-left:32px}.m-right-32{margin-right:32px}.m-top-32{margin-top:32px}.m-bottom-32{margin-bottom:32px}.gap-36{gap:36px}.p-36{padding:36px}.p-left-36{padding-left:36px}.p-right-36{padding-right:36px}.p-top-36{padding-top:36px}.p-bottom-36{padding-bottom:36px}.m-36{margin:36px}.m-left-36{margin-left:36px}.m-right-36{margin-right:36px}.m-top-36{margin-top:36px}.m-bottom-36{margin-bottom:36px}.gap-40{gap:40px}.p-40{padding:40px}.p-left-40{padding-left:40px}.p-right-40{padding-right:40px}.p-top-40{padding-top:40px}.p-bottom-40{padding-bottom:40px}.m-40{margin:40px}.m-left-40{margin-left:40px}.m-right-40{margin-right:40px}.m-top-40{margin-top:40px}.m-bottom-40{margin-bottom:40px}.gap-44{gap:44px}.p-44{padding:44px}.p-left-44{padding-left:44px}.p-right-44{padding-right:44px}.p-top-44{padding-top:44px}.p-bottom-44{padding-bottom:44px}.m-44{margin:44px}.m-left-44{margin-left:44px}.m-right-44{margin-right:44px}.m-top-44{margin-top:44px}.m-bottom-44{margin-bottom:44px}.gap-48{gap:48px}.p-48{padding:48px}.p-left-48{padding-left:48px}.p-right-48{padding-right:48px}.p-top-48{padding-top:48px}.p-bottom-48{padding-bottom:48px}.m-48{margin:48px}.m-left-48{margin-left:48px}.m-right-48{margin-right:48px}.m-top-48{margin-top:48px}.m-bottom-48{margin-bottom:48px}
+.gap-2 {
+ gap: 2px;
+}
+.p-2 {
+ padding: 2px;
+}
+.p-left-2 {
+ padding-left: 2px;
+}
+.p-right-2 {
+ padding-right: 2px;
+}
+.p-top-2 {
+ padding-top: 2px;
+}
+.p-bottom-2 {
+ padding-bottom: 2px;
+}
+.m-2 {
+ margin: 2px;
+}
+.m-left-2 {
+ margin-left: 2px;
+}
+.m-right-2 {
+ margin-right: 2px;
+}
+.m-top-2 {
+ margin-top: 2px;
+}
+.m-bottom-2 {
+ margin-bottom: 2px;
+}
+.gap-4 {
+ gap: 4px;
+}
+.p-4 {
+ padding: 4px;
+}
+.p-left-4 {
+ padding-left: 4px;
+}
+.p-right-4 {
+ padding-right: 4px;
+}
+.p-top-4 {
+ padding-top: 4px;
+}
+.p-bottom-4 {
+ padding-bottom: 4px;
+}
+.m-4 {
+ margin: 4px;
+}
+.m-left-4 {
+ margin-left: 4px;
+}
+.m-right-4 {
+ margin-right: 4px;
+}
+.m-top-4 {
+ margin-top: 4px;
+}
+.m-bottom-4 {
+ margin-bottom: 4px;
+}
+.gap-6 {
+ gap: 6px;
+}
+.p-6 {
+ padding: 6px;
+}
+.p-left-6 {
+ padding-left: 6px;
+}
+.p-right-6 {
+ padding-right: 6px;
+}
+.p-top-6 {
+ padding-top: 6px;
+}
+.p-bottom-6 {
+ padding-bottom: 6px;
+}
+.m-6 {
+ margin: 6px;
+}
+.m-left-6 {
+ margin-left: 6px;
+}
+.m-right-6 {
+ margin-right: 6px;
+}
+.m-top-6 {
+ margin-top: 6px;
+}
+.m-bottom-6 {
+ margin-bottom: 6px;
+}
+.gap-8 {
+ gap: 8px;
+}
+.p-8 {
+ padding: 8px;
+}
+.p-left-8 {
+ padding-left: 8px;
+}
+.p-right-8 {
+ padding-right: 8px;
+}
+.p-top-8 {
+ padding-top: 8px;
+}
+.p-bottom-8 {
+ padding-bottom: 8px;
+}
+.m-8 {
+ margin: 8px;
+}
+.m-left-8 {
+ margin-left: 8px;
+}
+.m-right-8 {
+ margin-right: 8px;
+}
+.m-top-8 {
+ margin-top: 8px;
+}
+.m-bottom-8 {
+ margin-bottom: 8px;
+}
+.gap-10 {
+ gap: 10px;
+}
+.p-10 {
+ padding: 10px;
+}
+.p-left-10 {
+ padding-left: 10px;
+}
+.p-right-10 {
+ padding-right: 10px;
+}
+.p-top-10 {
+ padding-top: 10px;
+}
+.p-bottom-10 {
+ padding-bottom: 10px;
+}
+.m-10 {
+ margin: 10px;
+}
+.m-left-10 {
+ margin-left: 10px;
+}
+.m-right-10 {
+ margin-right: 10px;
+}
+.m-top-10 {
+ margin-top: 10px;
+}
+.m-bottom-10 {
+ margin-bottom: 10px;
+}
+.gap-12 {
+ gap: 12px;
+}
+.p-12 {
+ padding: 12px;
+}
+.p-left-12 {
+ padding-left: 12px;
+}
+.p-right-12 {
+ padding-right: 12px;
+}
+.p-top-12 {
+ padding-top: 12px;
+}
+.p-bottom-12 {
+ padding-bottom: 12px;
+}
+.m-12 {
+ margin: 12px;
+}
+.m-left-12 {
+ margin-left: 12px;
+}
+.m-right-12 {
+ margin-right: 12px;
+}
+.m-top-12 {
+ margin-top: 12px;
+}
+.m-bottom-12 {
+ margin-bottom: 12px;
+}
+.gap-14 {
+ gap: 14px;
+}
+.p-14 {
+ padding: 14px;
+}
+.p-left-14 {
+ padding-left: 14px;
+}
+.p-right-14 {
+ padding-right: 14px;
+}
+.p-top-14 {
+ padding-top: 14px;
+}
+.p-bottom-14 {
+ padding-bottom: 14px;
+}
+.m-14 {
+ margin: 14px;
+}
+.m-left-14 {
+ margin-left: 14px;
+}
+.m-right-14 {
+ margin-right: 14px;
+}
+.m-top-14 {
+ margin-top: 14px;
+}
+.m-bottom-14 {
+ margin-bottom: 14px;
+}
+.gap-16 {
+ gap: 16px;
+}
+.p-16 {
+ padding: 16px;
+}
+.p-left-16 {
+ padding-left: 16px;
+}
+.p-right-16 {
+ padding-right: 16px;
+}
+.p-top-16 {
+ padding-top: 16px;
+}
+.p-bottom-16 {
+ padding-bottom: 16px;
+}
+.m-16 {
+ margin: 16px;
+}
+.m-left-16 {
+ margin-left: 16px;
+}
+.m-right-16 {
+ margin-right: 16px;
+}
+.m-top-16 {
+ margin-top: 16px;
+}
+.m-bottom-16 {
+ margin-bottom: 16px;
+}
+.gap-20 {
+ gap: 20px;
+}
+.p-20 {
+ padding: 20px;
+}
+.p-left-20 {
+ padding-left: 20px;
+}
+.p-right-20 {
+ padding-right: 20px;
+}
+.p-top-20 {
+ padding-top: 20px;
+}
+.p-bottom-20 {
+ padding-bottom: 20px;
+}
+.m-20 {
+ margin: 20px;
+}
+.m-left-20 {
+ margin-left: 20px;
+}
+.m-right-20 {
+ margin-right: 20px;
+}
+.m-top-20 {
+ margin-top: 20px;
+}
+.m-bottom-20 {
+ margin-bottom: 20px;
+}
+.gap-24 {
+ gap: 24px;
+}
+.p-24 {
+ padding: 24px;
+}
+.p-left-24 {
+ padding-left: 24px;
+}
+.p-right-24 {
+ padding-right: 24px;
+}
+.p-top-24 {
+ padding-top: 24px;
+}
+.p-bottom-24 {
+ padding-bottom: 24px;
+}
+.m-24 {
+ margin: 24px;
+}
+.m-left-24 {
+ margin-left: 24px;
+}
+.m-right-24 {
+ margin-right: 24px;
+}
+.m-top-24 {
+ margin-top: 24px;
+}
+.m-bottom-24 {
+ margin-bottom: 24px;
+}
+.gap-28 {
+ gap: 28px;
+}
+.p-28 {
+ padding: 28px;
+}
+.p-left-28 {
+ padding-left: 28px;
+}
+.p-right-28 {
+ padding-right: 28px;
+}
+.p-top-28 {
+ padding-top: 28px;
+}
+.p-bottom-28 {
+ padding-bottom: 28px;
+}
+.m-28 {
+ margin: 28px;
+}
+.m-left-28 {
+ margin-left: 28px;
+}
+.m-right-28 {
+ margin-right: 28px;
+}
+.m-top-28 {
+ margin-top: 28px;
+}
+.m-bottom-28 {
+ margin-bottom: 28px;
+}
+.gap-32 {
+ gap: 32px;
+}
+.p-32 {
+ padding: 32px;
+}
+.p-left-32 {
+ padding-left: 32px;
+}
+.p-right-32 {
+ padding-right: 32px;
+}
+.p-top-32 {
+ padding-top: 32px;
+}
+.p-bottom-32 {
+ padding-bottom: 32px;
+}
+.m-32 {
+ margin: 32px;
+}
+.m-left-32 {
+ margin-left: 32px;
+}
+.m-right-32 {
+ margin-right: 32px;
+}
+.m-top-32 {
+ margin-top: 32px;
+}
+.m-bottom-32 {
+ margin-bottom: 32px;
+}
+.gap-36 {
+ gap: 36px;
+}
+.p-36 {
+ padding: 36px;
+}
+.p-left-36 {
+ padding-left: 36px;
+}
+.p-right-36 {
+ padding-right: 36px;
+}
+.p-top-36 {
+ padding-top: 36px;
+}
+.p-bottom-36 {
+ padding-bottom: 36px;
+}
+.m-36 {
+ margin: 36px;
+}
+.m-left-36 {
+ margin-left: 36px;
+}
+.m-right-36 {
+ margin-right: 36px;
+}
+.m-top-36 {
+ margin-top: 36px;
+}
+.m-bottom-36 {
+ margin-bottom: 36px;
+}
+.gap-40 {
+ gap: 40px;
+}
+.p-40 {
+ padding: 40px;
+}
+.p-left-40 {
+ padding-left: 40px;
+}
+.p-right-40 {
+ padding-right: 40px;
+}
+.p-top-40 {
+ padding-top: 40px;
+}
+.p-bottom-40 {
+ padding-bottom: 40px;
+}
+.m-40 {
+ margin: 40px;
+}
+.m-left-40 {
+ margin-left: 40px;
+}
+.m-right-40 {
+ margin-right: 40px;
+}
+.m-top-40 {
+ margin-top: 40px;
+}
+.m-bottom-40 {
+ margin-bottom: 40px;
+}
+.gap-44 {
+ gap: 44px;
+}
+.p-44 {
+ padding: 44px;
+}
+.p-left-44 {
+ padding-left: 44px;
+}
+.p-right-44 {
+ padding-right: 44px;
+}
+.p-top-44 {
+ padding-top: 44px;
+}
+.p-bottom-44 {
+ padding-bottom: 44px;
+}
+.m-44 {
+ margin: 44px;
+}
+.m-left-44 {
+ margin-left: 44px;
+}
+.m-right-44 {
+ margin-right: 44px;
+}
+.m-top-44 {
+ margin-top: 44px;
+}
+.m-bottom-44 {
+ margin-bottom: 44px;
+}
+.gap-48 {
+ gap: 48px;
+}
+.p-48 {
+ padding: 48px;
+}
+.p-left-48 {
+ padding-left: 48px;
+}
+.p-right-48 {
+ padding-right: 48px;
+}
+.p-top-48 {
+ padding-top: 48px;
+}
+.p-bottom-48 {
+ padding-bottom: 48px;
+}
+.m-48 {
+ margin: 48px;
+}
+.m-left-48 {
+ margin-left: 48px;
+}
+.m-right-48 {
+ margin-right: 48px;
+}
+.m-top-48 {
+ margin-top: 48px;
+}
+.m-bottom-48 {
+ margin-bottom: 48px;
+}
/* Position */
-.relative { position: relative; } .absolute { position: absolute; } .fixed { position: fixed; } .sticky { position: sticky; } .top-0 { top: 0; } .bottom-0 { bottom: 0; } .left-0 { left: 0; } .right-0 { right: 0; }
+.relative {
+ position: relative;
+}
+.absolute {
+ position: absolute;
+}
+.fixed {
+ position: fixed;
+}
+.sticky {
+ position: sticky;
+}
+.top-0 {
+ top: 0;
+}
+.bottom-0 {
+ bottom: 0;
+}
+.left-0 {
+ left: 0;
+}
+.right-0 {
+ right: 0;
+}
/* Size */
-.full-width { width: 100%; } .full-height { height: 100%; }
+.full-width {
+ width: 100%;
+}
+.full-height {
+ height: 100%;
+}
/* Flexbox */
.flex {
@@ -29,9 +584,6 @@
.full-width {
width: 100%;
}
-.full-height {
- height: 100%;
-}
.flex-1 {
flex: 1;
}