Listing series

This commit is contained in:
Caleb Owens 2024-10-16 17:49:55 +02:00
parent 1345c324db
commit 2c00d5f869
14 changed files with 264 additions and 109 deletions

View File

@ -4,7 +4,7 @@ import * as toasts from '$lib/utils/toasts';
import { persisted } from '@gitbutler/shared/persisted';
import { open } from '@tauri-apps/api/dialog';
import { plainToInstance } from 'class-transformer';
import { get, writable } from 'svelte/store';
import { derived, get, writable, type Readable } from 'svelte/store';
import type { HttpClient } from '@gitbutler/shared/httpClient';
import { goto } from '$app/navigation';
@ -81,6 +81,18 @@ export class ProjectService {
return plainToInstance(Project, await invoke('get_project', { id: projectId, noValidation }));
}
#projectStores = new Map<string, Readable<Project | undefined>>();
getProjectStore(projectId: string) {
let store = this.#projectStores.get(projectId);
if (store) return store;
store = derived(this.projects, (projects) => {
return projects.find((p) => p.id === projectId);
});
this.#projectStores.set(projectId, store);
return store;
}
async updateProject(project: Project) {
plainToInstance(Project, await invoke('update_project', { project: project }));
this.reload();

View File

@ -0,0 +1,34 @@
<script lang="ts">
import DomainButton from '$lib/navigation/DomainButton.svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
interface Props {
href: string;
isNavCollapsed: boolean;
}
const { href, isNavCollapsed }: Props = $props();
const label = 'Series';
</script>
<DomainButton
isSelected={$page.url.pathname === href}
{isNavCollapsed}
tooltipLabel={label}
onmousedown={async () => await goto(href)}
>
<img class="icon" src="/images/domain-icons/working-branches.svg" alt="" />
{#if !isNavCollapsed}
<span class="text-14 text-semibold" class:collapsed-txt={isNavCollapsed}>{label}</span>
{/if}
</DomainButton>
<style lang="postcss">
.icon {
border-radius: var(--radius-s);
height: 20px;
width: 20px;
flex-shrink: 0;
}
</style>

View File

@ -5,7 +5,7 @@
import TargetCard from './TargetCard.svelte';
import WorkspaceButton from './WorkspaceButton.svelte';
import Resizer from '../shared/Resizer.svelte';
import { Project } from '$lib/backend/projects';
import { Project, ProjectService } from '$lib/backend/projects';
import { featureTopics } from '$lib/config/uiFeatureFlags';
import { ModeService } from '$lib/modes/service';
import EditButton from '$lib/navigation/EditButton.svelte';
@ -16,11 +16,14 @@
import { getContext, getContextStoreBySymbol } from '@gitbutler/shared/context';
import { persisted } from '@gitbutler/shared/persisted';
import { env } from '$env/dynamic/public';
import CloudSeriesButton from '$lib/navigation/CloudSeriesButton.svelte';
const minResizerWidth = 280;
const minResizerRatio = 150;
const userSettings = getContextStoreBySymbol<Settings>(SETTINGS);
const project = getContext(Project);
const projectService = getContext(ProjectService);
const project$ = projectService.getProjectStore(project.id);
const defaultTrayWidthRem = persisted<number | undefined>(
undefined,
'defaulTrayWidth_ ' + project.id
@ -126,6 +129,10 @@
{#if $topicsEnabled}
<TopicsButton href={`/${project.id}/topics`} isNavCollapsed={$isNavCollapsed} />
{/if}
{#if $project$?.api?.sync}
<CloudSeriesButton href={`/${$project$.id}/series`} isNavCollapsed={$isNavCollapsed} />
{/if}
</div>
</div>

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { Project, ProjectService } from '$lib/backend/projects';
import { Project } from '$lib/backend/projects';
import AiPromptSelect from '$lib/components/AIPromptSelect.svelte';
import SectionCard from '$lib/components/SectionCard.svelte';
import WelcomeSigninAction from '$lib/components/WelcomeSigninAction.svelte';
@ -10,24 +10,13 @@
import { UserService } from '$lib/stores/user';
import { getContext } from '@gitbutler/shared/context';
import Button from '@gitbutler/ui/Button.svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
const userService = getContext(UserService);
const projectService = getContext(ProjectService);
const project = getContext(Project);
const user = userService.user;
const aiGenEnabled = projectAiGenEnabled(project.id);
onMount(async () => {
if (!project?.api) return;
if (!$user) return;
const cloudProject = await projectService.getCloudProject(project.api.repository_id);
if (cloudProject === project.api) return;
project.api = { ...cloudProject, sync: project.api.sync, sync_code: project.api.sync_code };
projectService.updateProject(project);
});
</script>
<Section>

View File

@ -10,6 +10,7 @@
import { User } from '$lib/stores/user';
import * as toasts from '$lib/utils/toasts';
import { getContext, getContextStore } from '@gitbutler/shared/context';
import { onMount } from 'svelte';
import { PUBLIC_API_BASE_URL } from '$env/static/public';
const project = getContext(Project);
@ -19,18 +20,6 @@
let title = project?.title;
let description = project?.description;
async function saveProject() {
const api =
$user && project.api
? await projectService.updateCloudProject(project.api.repository_id, {
name: project.title,
description: project.description
})
: undefined;
project.api = api ? { ...api, sync: false, sync_code: undefined } : undefined;
projectService.updateProject(project);
}
async function onSyncChange(sync: boolean) {
if (!$user) return;
try {
@ -66,6 +55,16 @@
toasts.error('Failed to update project sync status');
}
}
// This is some janky bullshit, but it works well enough for now
onMount(async () => {
if (!project?.api) return;
if (!$user) return;
console.log(project);
const cloudProject = await projectService.getCloudProject(project.api.repository_id);
project.api = { ...cloudProject, sync: project.api.sync, sync_code: project.api.sync_code };
projectService.updateProject(project);
});
</script>
<SectionCard>
@ -81,7 +80,7 @@
required
on:change={(e) => {
project.title = e.detail;
saveProject();
projectService.updateProject(project);
}}
/>
<TextArea
@ -91,7 +90,7 @@
bind:value={description}
on:change={() => {
project.description = description;
saveProject();
projectService.updateProject(project);
}}
maxHeight={300}
/>
@ -114,7 +113,7 @@
<Toggle
id="historySync"
checked={project.api?.sync || false}
on:click={async (e) => await onSyncChange(!!e.detail)}
on:click={async () => await onSyncChange(!project.api?.sync)}
/>
</svelte:fragment>
</SectionCard>
@ -126,7 +125,7 @@
<Toggle
id="branchesySync"
checked={project.api?.sync_code || false}
on:click={async (e) => await onSyncCodeChange(!!e.detail)}
on:click={async () => await onSyncCodeChange(!project.api?.sync_code)}
/>
</svelte:fragment>
</SectionCard>

View File

@ -33,6 +33,7 @@
import { BranchController } from '$lib/vbranches/branchController';
import { UpstreamIntegrationService } from '$lib/vbranches/upstreamIntegrationService';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
import { CloudPatchStacksService } from '@gitbutler/shared/cloud/stacks/service';
import { onDestroy, setContext, type Snippet } from 'svelte';
import { derived as storeDerived } from 'svelte/store';
import type { LayoutData } from './$types';
@ -78,6 +79,7 @@
setContext(ModeService, data.modeService);
setContext(UncommitedFilesWatcher, data.uncommitedFileWatcher);
setContext(UpstreamIntegrationService, data.upstreamIntegrationService);
setContext(CloudPatchStacksService, data.cloudPatchStacksService);
});
let intervalId: any;

View File

@ -14,7 +14,12 @@ import { UncommitedFilesWatcher } from '$lib/uncommitedFiles/watcher';
import { BranchController } from '$lib/vbranches/branchController';
import { UpstreamIntegrationService } from '$lib/vbranches/upstreamIntegrationService';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
import {
PatchStacksApiService,
CloudPatchStacksService
} from '@gitbutler/shared/cloud/stacks/service';
import { error } from '@sveltejs/kit';
import { derived } from 'svelte/store';
import type { Project } from '$lib/backend/projects';
import type { LayoutLoad } from './$types';
@ -22,11 +27,7 @@ export const prerender = false;
// eslint-disable-next-line
export const load: LayoutLoad = async ({ params, parent }) => {
// prettier-ignore
const {
authService,
projectService,
} = await parent();
const { authService, projectService, cloud } = await parent();
const projectId = params.projectId;
projectService.setLastOpenedProject(projectId);
@ -84,6 +85,11 @@ export const load: LayoutLoad = async ({ params, parent }) => {
const uncommitedFileWatcher = new UncommitedFilesWatcher(project);
const upstreamIntegrationService = new UpstreamIntegrationService(project, vbranchService);
const repositoryId = derived(projectService.getProjectStore(projectId), (project) => {
return project?.api?.repository_id;
});
const patchStacksApiService = new PatchStacksApiService(cloud);
const cloudPatchStacksService = new CloudPatchStacksService(repositoryId, patchStacksApiService);
return {
authService,
@ -99,6 +105,7 @@ export const load: LayoutLoad = async ({ params, parent }) => {
modeService,
fetchSignal,
upstreamIntegrationService,
cloudPatchStacksService,
// These observables are provided for convenience
branchDragActionsFactory,

View File

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

View File

@ -7,7 +7,7 @@ use anyhow::{anyhow, Context, Result};
use gitbutler_command_context::CommandContext;
use gitbutler_error::error::Code;
use gitbutler_id::id::Id;
use gitbutler_oplog::OplogExt;
use gitbutler_oplog::{/*entry::SnapshotDetails,*/ OplogExt};
use gitbutler_project as projects;
use gitbutler_project::{CodePushState, Project};
use gitbutler_reference::Refname;
@ -16,6 +16,11 @@ use gitbutler_url::Url;
use gitbutler_user as users;
use itertools::Itertools;
// pub fn take_synced_snapshot(project: &Project, user: &users::User) -> Result<git2::Oid> {
// let command_context = CommandContext::open(project)?;
// project.create_snapshot(SnapshotDetails::new(), perm)
// }
/// Pushes the repository to the GitButler remote
pub fn push_repo(
ctx: &CommandContext,

View File

@ -5,6 +5,7 @@ use gitbutler_diff::FileDiff;
use gitbutler_oplog::{entry::Snapshot, OplogExt};
use gitbutler_project as projects;
use gitbutler_project::ProjectId;
// use gitbutler_user::User;
use tauri::State;
use tracing::instrument;
@ -50,3 +51,15 @@ pub fn snapshot_diff(
let diff = project.snapshot_diff(sha.parse().map_err(anyhow::Error::from)?)?;
Ok(diff)
}
// #[tauri::command(async)]
// #[instrument(skip(projects), err(Debug))]
// pub fn take_synced_snapshot(
// projects: State<'_, projects::Controller>,
// project_id: ProjectId,
// user: User,
// ) -> Result<git2::Oid, Error> {
// let project = projects.get(project_id).context("failed to get project")?;
// let snapshot_oid = gitbutler_sync::cloud::take_synced_snapshot(&project, &user)?;
// Ok(snapshot_oid)
// }

View File

@ -0,0 +1,16 @@
<script lang="ts">
import { CloudPatchStacksService } from '$lib/cloud/stacks/service';
import { getContext } from '$lib/context';
/**
* Expects the following contexts:
* - PatchStacksService
*/
const patchStacksService = getContext(CloudPatchStacksService);
const patchStacks = $derived(patchStacksService.patchStacks);
</script>
{#each $patchStacks as patchStack}
<div>{patchStack.title}</div>
{/each}

View File

@ -1,4 +1,5 @@
import { derived, writable, type Readable } from 'svelte/store';
import { writableDerived } from '$lib/storeUtils';
import { derived, get, type Readable, type Writable } from 'svelte/store';
import type { HttpClient } from '$lib/httpClient';
interface ApiPatchStatstics {
@ -9,12 +10,12 @@ interface ApiPatchStatstics {
files: string[];
}
class PatchStatstics {
fileCount: number;
sectionCount: number;
lines: number;
deletions: number;
files: string[];
export class CloudPatchStatsitics {
readonly fileCount: number;
readonly sectionCount: number;
readonly lines: number;
readonly deletions: number;
readonly files: string[];
constructor(apiPatchStatstics: ApiPatchStatstics) {
this.fileCount = apiPatchStatstics.file_count;
@ -31,10 +32,10 @@ interface ApiPatchReview {
rejected: boolean;
}
class PatchReview {
viewed: boolean;
signedOff: boolean;
rejected: boolean;
export class CloudPatchReview {
readonly viewed: boolean;
readonly signedOff: boolean;
readonly rejected: boolean;
constructor(apiPatchReview: ApiPatchReview) {
this.viewed = apiPatchReview.viewed;
@ -57,8 +58,7 @@ interface ApiPatch {
review_all: ApiPatchReview[];
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class Patch {
export class CloudPatch {
changeId: string;
commitSha: string;
title?: string;
@ -66,9 +66,9 @@ class Patch {
position: number;
version: number;
contributors: string[];
statistics: PatchStatstics;
review: PatchReview;
reviewAll: PatchReview[];
statistics: CloudPatchStatsitics;
review: CloudPatchReview;
reviewAll: CloudPatchReview[];
constructor(apiPatch: ApiPatch) {
this.changeId = apiPatch.change_id;
@ -78,13 +78,13 @@ class Patch {
this.position = apiPatch.position || 0;
this.version = apiPatch.version || 0;
this.contributors = apiPatch.contributors;
this.statistics = new PatchStatstics(apiPatch.statistics);
this.review = new PatchReview(apiPatch.review);
this.reviewAll = apiPatch.review_all.map((review) => new PatchReview(review));
this.statistics = new CloudPatchStatsitics(apiPatch.statistics);
this.review = new CloudPatchReview(apiPatch.review);
this.reviewAll = apiPatch.review_all.map((review) => new CloudPatchReview(review));
}
}
const enum PatchStackStatus {
export const enum CloudPatchStackStatus {
Active = 'active',
Inactive = 'inactive',
Closed = 'closed',
@ -98,7 +98,7 @@ interface ApiPatchStack {
uuid: string;
title?: string;
description?: string;
status?: PatchStackStatus;
status?: CloudPatchStackStatus;
version?: number;
created_at: string;
stack_size?: number;
@ -106,13 +106,13 @@ interface ApiPatchStack {
patches: ApiPatch[];
}
class PatchStack {
export class CloudPatchStack {
branchId: string;
oplogSha?: string;
uuid: string;
title?: string;
description?: string;
status?: PatchStackStatus;
status?: CloudPatchStackStatus;
version: number;
createdAt: string;
stackSize: number;
@ -136,32 +136,35 @@ class PatchStack {
}
}
interface PatchStackCreationParams {
export interface PatchStackCreationParams {
branch_id: string;
oplog_sha: string;
}
interface PatchStackUpdateParams {
export interface PatchStackUpdateParams {
status: 'active' | 'closed';
title: string;
description: string;
}
export class PatchStacksApiService {
constructor(
private readonly repositoryId: string,
private readonly httpClient: HttpClient
) {}
constructor(private readonly httpClient: HttpClient) {}
async getPatchStacks(status: PatchStackStatus = PatchStackStatus.All): Promise<ApiPatchStack[]> {
// TODO(CTO): Support optional filtering query params `branch_id` and `status`
async getPatchStacks(
repositoryId: string,
status: CloudPatchStackStatus = CloudPatchStackStatus.All
): Promise<ApiPatchStack[]> {
// TODO(CTO): Support optional filtering query param `branch_id`
return await this.httpClient.get<ApiPatchStack[]>(
`/patch_stack/${this.repositoryId}?status=${status}`
`patch_stack/${repositoryId}?status=${status}`
);
}
async createPatchStack(params: PatchStackCreationParams): Promise<ApiPatchStack> {
return await this.httpClient.post<ApiPatchStack>(`/patch_stack/${this.repositoryId}`, {
async createPatchStack(
repositoryId: string,
params: PatchStackCreationParams
): Promise<ApiPatchStack> {
return await this.httpClient.post<ApiPatchStack>(`patch_stack/${repositoryId}`, {
body: params
});
}
@ -170,7 +173,7 @@ export class PatchStacksApiService {
patchStackUuid: string,
params: PatchStackUpdateParams
): Promise<ApiPatchStack> {
return await this.httpClient.put<ApiPatchStack>(`/patch_stack/${patchStackUuid}`, {
return await this.httpClient.put<ApiPatchStack>(`patch_stack/${patchStackUuid}`, {
body: params
});
}
@ -184,43 +187,60 @@ const MINUTES_15 = 15 * 60 * 1000;
* The list of patch stacks is kept up-to-date automatically, whenever
* operations on a patch stack have been performed, or every 15 minutes.
*/
export class PatchStacksService {
#apiPatchStacks = writable<ApiPatchStack[]>([], (set) => {
let canceled = false;
const callback = (() => {
this.patchStacksApiService.getPatchStacks().then((patchStacks) => {
if (!canceled) set(patchStacks);
});
}).bind(this);
// Automatically refresh every 15 minutes
callback();
const interval = setInterval(callback, MINUTES_15);
return () => {
canceled = true;
clearInterval(interval);
};
});
#patchStacks = derived(this.#apiPatchStacks, (apiPatchStacks) => {
return apiPatchStacks.map((apiPatchStack) => new PatchStack(apiPatchStack));
});
export class CloudPatchStacksService {
#apiPatchStacks: Writable<ApiPatchStack[]>;
#patchStacks;
constructor(
private readonly _repositoryId: string,
private readonly repositoryId: Readable<string | undefined>,
private readonly patchStacksApiService: PatchStacksApiService
) {}
) {
this.#apiPatchStacks = writableDerived<ApiPatchStack[], string | undefined>(
this.repositoryId,
[],
(repositoryId, set) => {
if (!repositoryId) {
set([]);
return;
}
let canceled = false;
const callback = (() => {
this.patchStacksApiService.getPatchStacks(repositoryId).then((patchStacks) => {
if (!canceled) set(patchStacks);
});
}).bind(this);
// Automatically refresh every 15 minutes
callback();
const interval = setInterval(callback, MINUTES_15);
return () => {
canceled = true;
clearInterval(interval);
};
}
);
this.#patchStacks = derived(this.#apiPatchStacks, (apiPatchStacks) => {
return apiPatchStacks.map((apiPatchStack) => new CloudPatchStack(apiPatchStack));
});
}
/** An unordered list of patch stacks for a given repository */
get patchStacks(): Readable<PatchStack[]> {
get patchStacks(): Readable<CloudPatchStack[]> {
return this.#patchStacks;
}
/** Refresh the list of patch stacks */
async refresh(): Promise<void> {
const patchStacks = await this.patchStacksApiService.getPatchStacks();
const repositoryId = get(this.repositoryId);
if (!repositoryId) {
this.#apiPatchStacks.set([]);
return;
}
const patchStacks = await this.patchStacksApiService.getPatchStacks(repositoryId);
this.#apiPatchStacks.set(patchStacks);
}
}

View File

@ -1,11 +0,0 @@
<script lang="ts">
import { getContext } from '$lib/context';
import { PatchStacksService } from '$lib/stacks/service';
/**
* Expects the following contexts:
* - PatchStacksService
*/
const _patchStacksService = getContext(PatchStacksService);
</script>

View File

@ -0,0 +1,57 @@
/**
* This module contains some utilities primarily inspired by RxJS that can
* help us avoid manually managing subscriptions and unsubscriptions in
* application code.
*/
import { writable, type Readable } from 'svelte/store';
export function combineLatest<T>(stores: Readable<T>[], initialValue: T): Readable<T> {
const store = writable(initialValue, (set) => {
const unsubscribers = stores.map((store) => store.subscribe(set));
return () => {
unsubscribers.forEach((unsubscribe) => unsubscribe());
};
});
return store;
}
/**
* Like a writable, but the startStopNotifier gets re-run whenever the provided store changes.
*
* If the source store is changed frequently, consider throttling or debouncing it as it will
* cause the StartStopNotifier to be called frequently.
*
* Example lifecycle:
*
* o Readable = 1
* |
* o - o writableDerived subscribed (ssn called, passed value 1, ssn returns 3) wD, = 3
* | |
* o o Readable updated to 2, (ssn unsubscribers called, ssn called, passed value 2, ssn returns 4), wD = 4
* | |
* | o writable derived set to 6, wD = 6
* | |
* | o writableDerived unsubscribed (ssn unsubscribers called)
*/
export function writableDerived<A, B>(
store: Readable<B>,
initialValue: A,
startStopNotifier: (derivedValue: B, set: (value: A) => void) => (() => void) | undefined
) {
const derivedStore = writable(initialValue, (set) => {
let startStopUnsubscribe: (() => void) | undefined = undefined;
const unsubscribeStore = store.subscribe((value) => {
startStopUnsubscribe?.();
startStopUnsubscribe = startStopNotifier(value, set);
});
return () => {
unsubscribeStore();
startStopUnsubscribe?.();
};
});
return derivedStore;
}