Merge pull request #5209 from gitbutlerapp/branch

Patch stacks :D
This commit is contained in:
Caleb Owens 2024-10-18 16:39:16 +02:00 committed by GitHub
commit 928262a09b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 507 additions and 24 deletions

View File

@ -2,3 +2,4 @@ PUBLIC_API_BASE_URL=https://test.app.gitbutler.com/
PUBLIC_POSTHOG_API_KEY=phc_t7VDC9pQELnYep9IiDTxrq2HLseY5wyT7pn0EpHM7rr
PUBLIC_CHAIN_API=https://data-test.gitbutler.com/chain/
PUBLIC_SENTRY_ENVIRONMENT=development
PUBLIC_CLOUD_BASE_URL=https://cloud.gitbutler.com/

View File

@ -2,3 +2,4 @@ PUBLIC_API_BASE_URL=https://app.gitbutler.com/
PUBLIC_POSTHOG_API_KEY=phc_yJx46mXv6kA5KTuM2eEQ6IwNTgl5YW3feKV5gi7mfGG
PUBLIC_CHAIN_API=https://data.gitbutler.com/chain/
PUBLIC_SENTRY_ENVIRONMENT=nightly
PUBLIC_CLOUD_BASE_URL=https://cloud.gitbutler.com/

View File

@ -2,3 +2,4 @@ PUBLIC_API_BASE_URL=https://app.gitbutler.com/
PUBLIC_POSTHOG_API_KEY=phc_yJx46mXv6kA5KTuM2eEQ6IwNTgl5YW3feKV5gi7mfGG
PUBLIC_CHAIN_API=https://data.gitbutler.com/chain/
PUBLIC_SENTRY_ENVIRONMENT=production
PUBLIC_CLOUD_BASE_URL=https://cloud.gitbutler.com/

View File

@ -3,3 +3,4 @@ PUBLIC_POSTHOG_API_KEY=
PUBLIC_CHAIN_API=https://data-test.gitbutler.com/chain/
PUBLIC_SENTRY_ENVIRONMENT=
PUBLIC_TESTING=true
PUBLIC_CLOUD_BASE_URL=https://cloud.gitbutler.com/

View File

@ -35,6 +35,11 @@
import * as events from '$lib/utils/events';
import { unsubscribe } from '$lib/utils/unsubscribe';
import { HttpClient } from '@gitbutler/shared/httpClient';
import {
DesktopRoutesService,
setRoutesService,
WebRoutesService
} from '@gitbutler/shared/sharedRoutes';
import { LineManagerFactory } from '@gitbutler/ui/commitLines/lineManager';
import { LineManagerFactory as StackingLineManagerFactory } from '@gitbutler/ui/commitLinesStacking/lineManager';
import { onMount, setContext, type Snippet } from 'svelte';
@ -42,6 +47,7 @@
import type { LayoutData } from './$types';
import { dev } from '$app/environment';
import { goto } from '$app/navigation';
import { env } from '$env/dynamic/public';
const { data, children }: { data: LayoutData; children: Snippet } = $props();
@ -65,6 +71,10 @@
setContext(LineManagerFactory, data.lineManagerFactory);
setContext(StackingLineManagerFactory, data.stackingLineManagerFactory);
const webRoutesService = new WebRoutesService(true, env.PUBLIC_CLOUD_BASE_URL);
const desktopRoutesService = new DesktopRoutesService(webRoutesService);
setRoutesService(desktopRoutesService);
setNameNormalizationServiceContext(new IpcNameNormalizationService(invoke));
const user = data.userService.user;

View File

@ -36,6 +36,7 @@
import { UpstreamIntegrationService } from '$lib/vbranches/upstreamIntegrationService';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
import { CloudPatchStacksService } from '@gitbutler/shared/cloud/stacks/service';
import { DesktopRoutesService, getRoutesService } from '@gitbutler/shared/sharedRoutes';
import { onDestroy, setContext, type Snippet } from 'svelte';
import { derived as storeDerived } from 'svelte/store';
import type { LayoutData } from './$types';
@ -89,6 +90,13 @@
setContext(PatchStackCreationService, data.patchStackCreationService);
});
const routesService = getRoutesService();
$effect(() => {
if (routesService instanceof DesktopRoutesService) {
routesService.currentProjectId.set(projectId);
}
});
let intervalId: any;
const octokit = $derived(accessToken ? octokitFromAccessToken(accessToken) : undefined);

View File

@ -2,4 +2,23 @@
import CloudPatchStackIndex from '@gitbutler/shared/cloud/stacks/CloudPatchStackIndex.svelte';
</script>
<CloudPatchStackIndex />
<div class="series-container">
<h2 class="text-head-24 heading">Your patch stacks:</h2>
<CloudPatchStackIndex />
</div>
<style lang="postcss">
.heading {
margin-bottom: 16px;
}
.series-container {
display: flex;
flex-direction: column;
max-width: 600px;
width: 100%;
margin: 24px auto;
}
</style>

View File

@ -0,0 +1,24 @@
<script lang="ts">
import CloudPatchStackShow from '@gitbutler/shared/cloud/stacks/CloudPatchStackShow.svelte';
import { page } from '$app/stores';
const patchStackId = $derived($page.params.patchStackId);
</script>
<div class="patch-stack-container">
{#if patchStackId}
<CloudPatchStackShow {patchStackId} />
{/if}
</div>
<style lang="postcss">
.patch-stack-container {
display: flex;
flex-direction: column;
max-width: 600px;
width: 100%;
margin: 24px auto;
}
</style>

View File

@ -28,6 +28,8 @@
|
<a href="/projects">Projects</a>
|
<a href="/repositories">Repositories</a>
|
<a href="/user">User</a>
{/if}
</div>

View File

@ -3,7 +3,12 @@
import { AuthService } from '$lib/auth/authService';
import Navigation from '$lib/components/Navigation.svelte';
import { UserService } from '$lib/user/userService';
import {
CloudRepositoriesService,
RepositoriesApiService
} from '@gitbutler/shared/cloud/repositories/service';
import { HttpClient } from '@gitbutler/shared/httpClient';
import { WebRoutesService, setRoutesService } from '@gitbutler/shared/sharedRoutes';
import { setContext, type Snippet } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
@ -15,6 +20,9 @@
const { children }: Props = $props();
const webRoutesService = new WebRoutesService();
setRoutesService(webRoutesService);
const authService = new AuthService();
setContext(AuthService, authService);
@ -24,11 +32,15 @@
const userService = new UserService(httpClient);
setContext(UserService, userService);
const repositoriesApiService = new RepositoriesApiService(httpClient);
const cloudRepositoriesService = new CloudRepositoriesService(repositoriesApiService);
setContext(CloudRepositoriesService, cloudRepositoriesService);
$effect(() => {
if ($page.url.searchParams.get('gb_access_token')) {
const token = $page.url.searchParams.get('gb_access_token');
if (token && token.length > 0) {
$page.data.authService.setToken(token);
authService.setToken(token);
$page.url.searchParams.delete('gb_access_token');
goto(`?${$page.url.searchParams.toString()}`);

View File

@ -1 +1,2 @@
export const ssr = false;
export const csr = true;

View File

@ -0,0 +1,53 @@
<script lang="ts">
import { CloudRepositoriesService } from '@gitbutler/shared/cloud/repositories/service';
import { getContext } from '@gitbutler/shared/context';
import Button from '@gitbutler/ui/Button.svelte';
import { goto } from '$app/navigation';
const cloudRepositoriesService = getContext(CloudRepositoriesService);
const repositories = $derived(cloudRepositoriesService.repositories);
$inspect($repositories);
</script>
<h2>Your projects:</h2>
{#if !$repositories}
<p>Loading...</p>
{:else if $repositories.length === 0}
<p>
You've not got any projects added yet. Enable project syncing in GitButler in order to see
projects.
</p>
{:else}
<div class="card">
{#each $repositories as repository}
<div class="line-item">
<div>
<h5 class="text-head-22">{repository.name}</h5>
<p>{repository.name}</p>
</div>
<Button
style="pop"
kind="solid"
onclick={() => {
goto(`/repositories/${repository.repositoryId}`);
}}>Visit</Button
>
</div>
{/each}
</div>
{/if}
<style>
.line-item {
padding: 8px;
display: flex;
justify-content: space-between;
&:not(:last-child) {
border-bottom: 1px solid var(--clr-border-1);
}
}
</style>

View File

@ -0,0 +1,24 @@
<script lang="ts">
import {
CloudPatchStacksService,
PatchStacksApiService
} from '@gitbutler/shared/cloud/stacks/service';
import { getContext } from '@gitbutler/shared/context';
import { HttpClient } from '@gitbutler/shared/httpClient';
import { setContext, type Snippet } from 'svelte';
import { writable } from 'svelte/store';
import { page } from '$app/stores';
const { children }: { children: Snippet } = $props();
const repositoryId = writable<string | undefined>();
$effect(() => repositoryId.set($page.params.repositoryId));
const httpClient = getContext(HttpClient);
const patchStacksApiService = new PatchStacksApiService(httpClient);
const cloudPatchStacksService = new CloudPatchStacksService(repositoryId, patchStacksApiService);
setContext(CloudPatchStacksService, cloudPatchStacksService);
</script>
{@render children()}

View File

@ -0,0 +1,7 @@
<script lang="ts">
import CloudPatchStackIndex from '@gitbutler/shared/cloud/stacks/CloudPatchStackIndex.svelte';
</script>
<h2>Your patch stacks:</h2>
<CloudPatchStackIndex />

View File

@ -0,0 +1,10 @@
<script lang="ts">
import CloudPatchStackShow from '@gitbutler/shared/cloud/stacks/CloudPatchStackShow.svelte';
import { page } from '$app/stores';
const patchStackId = $derived($page.params.patchStackId);
</script>
{#if patchStackId}
<CloudPatchStackShow {patchStackId} />
{/if}

View File

@ -62,7 +62,8 @@
"svelte": "catalog:svelte",
"svelte-check": "catalog:svelte",
"vite": "catalog:",
"vitest": "^2.0.5"
"vitest": "^2.0.5",
"moment": "^2.30.1"
},
"type": "module"
}

View File

@ -0,0 +1,105 @@
import { writableDerived } from '$lib/storeUtils';
import { derived, get, type Readable, type Writable } from 'svelte/store';
import type { HttpClient } from '$lib/httpClient';
interface ApiRepository {
name: string;
description: string | null;
repository_id: string;
git_url: string;
created_at: string;
updated_at: string;
}
export class CloudRepository {
readonly name: string;
readonly description: string | null;
readonly repositoryId: string;
readonly gitUrl: string;
readonly createdAt: Date;
readonly updatedAt: Date;
constructor(apiRepository: ApiRepository) {
this.name = apiRepository.name;
this.description = apiRepository.description;
this.repositoryId = apiRepository.repository_id;
this.gitUrl = apiRepository.git_url;
this.createdAt = new Date(apiRepository.created_at);
this.updatedAt = new Date(apiRepository.updated_at);
}
}
export class RepositoriesApiService {
readonly canGetRepositories: Readable<boolean>;
constructor(private readonly httpClient: HttpClient) {
this.canGetRepositories = httpClient.authenticationAvailable;
}
async getRepositories(): Promise<ApiRepository[] | undefined> {
try {
return await this.httpClient.get<ApiRepository[]>('projects');
} catch (e) {
if (e instanceof TypeError) {
return undefined;
} else {
throw e;
}
}
}
}
const MINUTES_15 = 15 * 60 * 1000;
export class CloudRepositoriesService {
readonly #apiRepositories: Writable<ApiRepository[] | undefined>;
readonly repositories: Readable<CloudRepository[] | undefined>;
constructor(private readonly repositoriesApiService: RepositoriesApiService) {
this.#apiRepositories = writableDerived(
repositoriesApiService.canGetRepositories,
undefined as ApiRepository[] | undefined,
(canGetRepositories, set) => {
if (!canGetRepositories) {
set(undefined);
return;
}
let canceled = false;
const callback = (() => {
this.repositoriesApiService.getRepositories().then((apiRepositories) => {
if (!canceled) {
set(apiRepositories);
}
});
}).bind(this);
callback();
const timeout = setInterval(callback, MINUTES_15);
return () => {
canceled = true;
clearInterval(timeout);
};
}
);
this.repositories = derived(this.#apiRepositories, (apiRepositories) => {
return apiRepositories?.map((apiRepository) => new CloudRepository(apiRepository));
});
}
/** Refresh the list of patch stacks */
async refresh(): Promise<void> {
const canGetRepositories = get(this.repositoriesApiService.canGetRepositories);
if (canGetRepositories) {
const repositories = await this.repositoriesApiService.getRepositories();
this.#apiRepositories.set(repositories);
} else {
this.#apiRepositories.set(undefined);
}
}
}

View File

@ -1,18 +1,57 @@
<script lang="ts">
import { CloudPatchStacksService } from '$lib/cloud/stacks/service';
import { getContext } from '$lib/context';
import { getRoutesService } from '$lib/sharedRoutes';
import Button from '@gitbutler/ui/Button.svelte';
import moment from 'moment';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
/**
* Expects the following contexts:
* - PatchStacksService
* - CloudPatchStacksService
* - RoutesService
*/
const patchStacksService = getContext(CloudPatchStacksService);
const routesService = getRoutesService();
const patchStacks = $derived(patchStacksService.patchStacks);
</script>
{#if $patchStacks}
{#each $patchStacks as patchStack}
<div>{patchStack.title}</div>
{/each}
<div class="card">
{#each $patchStacks as patchStack}
<div class="line-item">
<div>
<p class="text-15 text-bold">{patchStack.title}</p>
<p>Version: v{patchStack.version}</p>
<p>Status: {patchStack.status}</p>
<p>Created: {moment(patchStack.createdAt).fromNow()}</p>
</div>
<Button
style="pop"
kind="solid"
onclick={() => {
const repositoryId = get(patchStacksService.repositoryId);
if (repositoryId) {
goto(routesService.patchStack(repositoryId, patchStack.uuid));
}
}}>Visit</Button
>
</div>
{/each}
</div>
{/if}
<style>
.line-item {
padding: 8px;
display: flex;
justify-content: space-between;
&:not(:last-child) {
border-bottom: 1px solid var(--clr-border-1);
}
}
</style>

View File

@ -0,0 +1,83 @@
<script lang="ts">
import { CloudPatchStacksService } from '$lib/cloud/stacks/service';
import { getContext } from '$lib/context';
import Button from '@gitbutler/ui/Button.svelte';
import moment from 'moment';
interface Props {
patchStackId: string;
}
const { patchStackId }: Props = $props();
const cloudPatchStacksService = getContext(CloudPatchStacksService);
const optionalPatchStack = $derived(cloudPatchStacksService.patchStackForId(patchStackId));
</script>
{#if $optionalPatchStack.state === 'uninitialized'}
<p>Loading...</p>
{:else if $optionalPatchStack.state === 'not-found'}
<p>Error: Stack not found</p>
{:else if $optionalPatchStack.state === 'found'}
{@const patchStack = $optionalPatchStack.value}
<h1 class="text-head-24 padding-bottom">{patchStack.title}</h1>
<div class="two-by-two padding-bottom">
<div class="card">
<div class="card__content">
<p>Version: {patchStack.version}</p>
<p>Status: {patchStack.status}</p>
<p>Created at: {moment(patchStack.createdAt).fromNow()}</p>
</div>
</div>
<div class="card">
<p class="card__header text-15 text-bold">Contributors:</p>
<div class="card__content">
<ul>
{#each patchStack.contributors as contributor}
<li>{contributor}</li>
{/each}
</ul>
</div>
</div>
</div>
<h2 class="text-head-20 padding-bottom">Patches: ({patchStack.patches.length})</h2>
<div class="card">
{#each patchStack.patches as patch}
<div class="line-item">
<div>
<p class="text-15 text-bold">{patch.title || 'Unnamed'}</p>
<p>Commit: {patch.commitSha.slice(0, 7)} - Change: {patch.changeId.slice(0, 7)}</p>
<p>Version: {patch.version}</p>
</div>
<Button style="pop" kind="solid">Visit</Button>
</div>
{/each}
</div>
{/if}
<style lang="postcss">
.padding-bottom {
margin-bottom: 16px;
}
.two-by-two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.line-item {
padding: 8px;
display: flex;
justify-content: space-between;
&:not(:last-child) {
border-bottom: 1px solid var(--clr-border-1);
}
}
</style>

View File

@ -55,7 +55,7 @@ interface ApiPatch {
contributors: string[];
statistics: ApiPatchStatstics;
review: ApiPatchReview;
review_all: ApiPatchReview[];
review_all: ApiPatchReview;
}
export class CloudPatch {
@ -68,7 +68,7 @@ export class CloudPatch {
contributors: string[];
statistics: CloudPatchStatsitics;
review: CloudPatchReview;
reviewAll: CloudPatchReview[];
reviewAll: CloudPatchReview;
constructor(apiPatch: ApiPatch) {
this.changeId = apiPatch.change_id;
@ -80,7 +80,7 @@ export class CloudPatch {
this.contributors = apiPatch.contributors;
this.statistics = new CloudPatchStatsitics(apiPatch.statistics);
this.review = new CloudPatchReview(apiPatch.review);
this.reviewAll = apiPatch.review_all.map((review) => new CloudPatchReview(review));
this.reviewAll = new CloudPatchReview(apiPatch.review_all);
}
}
@ -119,7 +119,7 @@ export class CloudPatchStack {
contributors: string[];
// TODO(CTO): Determine the best way to talk about these nested objects.
// Should they be in their own reactive service?
// patches: Patch[];
patches: CloudPatch[];
constructor(apiPatchStack: ApiPatchStack) {
this.branchId = apiPatchStack.branch_id;
@ -132,7 +132,7 @@ export class CloudPatchStack {
this.createdAt = apiPatchStack.created_at;
this.stackSize = apiPatchStack.stack_size || 0;
this.contributors = apiPatchStack.contributors;
// this.patches = apiPatchStack.patches?.map((patch) => new Patch(patch));
this.patches = apiPatchStack.patches?.map((patch) => new CloudPatch(patch));
}
}
@ -224,7 +224,7 @@ export class CloudPatchStacksService {
readonly patchStacks: Readable<CloudPatchStack[] | undefined>;
constructor(
private readonly repositoryId: Readable<string | undefined>,
readonly repositoryId: Readable<string | undefined>,
private readonly patchStacksApiService: PatchStacksApiService
) {
const values = derived(
@ -303,9 +303,9 @@ export class CloudPatchStacksService {
}
}
#patchStacksForBranchIds = new Map<string, Readable<LoadableOptional<CloudPatchStack>>>();
#patchStacksByBranchIds = new Map<string, Readable<LoadableOptional<CloudPatchStack>>>();
patchStackForBranchId(branchId: string): Readable<LoadableOptional<CloudPatchStack>> {
let store = this.#patchStacksForBranchIds.get(branchId);
let store = this.#patchStacksByBranchIds.get(branchId);
if (store) return store;
store = derived(this.patchStacks, (patchStacks): LoadableOptional<CloudPatchStack> => {
@ -317,7 +317,25 @@ export class CloudPatchStacksService {
return { state: 'not-found' };
}
});
this.#patchStacksForBranchIds.set(branchId, store);
this.#patchStacksByBranchIds.set(branchId, store);
return store;
}
#patchStacksByIds = new Map<string, Readable<LoadableOptional<CloudPatchStack>>>();
patchStackForId(patchStackId: string): Readable<LoadableOptional<CloudPatchStack>> {
let store = this.#patchStacksByIds.get(patchStackId);
if (store) return store;
store = derived(this.patchStacks, (patchStacks): LoadableOptional<CloudPatchStack> => {
if (!patchStacks) return { state: 'uninitialized' };
const patchStack = patchStacks.find((patchStack) => patchStack.uuid === patchStackId);
if (patchStack) {
return { state: 'found', value: patchStack };
} else {
return { state: 'not-found' };
}
});
this.#patchStacksByIds.set(patchStackId, store);
return store;
}
}

View File

@ -1,4 +0,0 @@
// Reexport your entry components here
export function foo() {
return 'foo';
}

View File

@ -0,0 +1,63 @@
import { buildContext } from '$lib/context';
import { get, writable, type Writable } from 'svelte/store';
export interface RoutesService {
repositories(): string;
repository(repositoryId: string): string;
patchStack(repositoryId: string, patchStackId: string): string;
}
export class WebRoutesService implements RoutesService {
constructor(
private readonly externalizePaths: boolean = false,
private readonly webBaseUrl?: string
) {}
repositories() {
return this.externalizePath('/repositories');
}
repository(repositoryId: string): string {
return this.externalizePath(`/repositories/${repositoryId}`);
}
patchStack(repositoryId: string, patchStackId: string): string {
return this.externalizePath(`/repositories/${repositoryId}/patchStacks/${patchStackId}`);
}
private externalizePath(path: string) {
if (this.externalizePaths) {
return new URL(path, this.webBaseUrl).href;
} else {
return path;
}
}
}
export class DesktopRoutesService implements RoutesService {
currentProjectId: Writable<string | undefined> = writable<string | undefined>();
constructor(private readonly webRoutesService: WebRoutesService) {}
repositories() {
return this.webRoutesService.repositories();
}
repository(repositoryId: string): string {
const projectId = get(this.currentProjectId);
if (projectId) {
return `/${projectId}/series`;
}
return this.webRoutesService.repository(repositoryId);
}
patchStack(repositoryId: string, patchStackId: string): string {
const projectId = get(this.currentProjectId);
if (projectId) {
return `/${projectId}/series/patchStacks/${patchStackId}`;
}
return this.webRoutesService.patchStack(repositoryId, patchStackId);
}
}
export const [getRoutesService, setRoutesService] = buildContext<RoutesService>('routes-service');

View File

@ -4,7 +4,7 @@
* application code.
*/
import { writable, type Readable } from 'svelte/store';
import { writable, type Readable, type Writable } from 'svelte/store';
export function combineLatest<T>(stores: Readable<T>[], initialValue: T): Readable<T> {
const store = writable(initialValue, (set) => {
@ -39,7 +39,7 @@ export function writableDerived<A, B>(
store: Readable<B>,
initialValue: A,
startStopNotifier: (derivedValue: B, set: (value: A) => void) => (() => void) | undefined
) {
): Writable<A> {
const derivedStore = writable(initialValue, (set) => {
let startStopUnsubscribe: (() => void) | undefined = undefined;
const unsubscribeStore = store.subscribe((value) => {

View File

@ -1,7 +1,7 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"target": "es6",
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2021"],
"allowJs": true,
"checkJs": false,
@ -11,7 +11,8 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"experimentalDecorators": true
"experimentalDecorators": true,
"declaration": true
},
"include": [
".svelte-kit/ambient.d.ts",

View File

@ -383,6 +383,9 @@ importers:
date-fns:
specifier: ^2.30.0
version: 2.30.0
moment:
specifier: ^2.30.1
version: 2.30.1
playwright:
specifier: ^1.46.1
version: 1.46.1