Small refactor of history component

This commit is contained in:
Mattias Granlund 2024-05-17 16:22:26 +02:00
parent 076ff9beed
commit c699ad4dfa
9 changed files with 196 additions and 220 deletions

View File

@ -1,68 +1,3 @@
<script lang="ts" context="module">
export type Trailer = {
key: string;
value: string;
};
export type Operation =
| 'CreateCommit'
| 'CreateBranch'
| 'SetBaseBranch'
| 'MergeUpstream'
| 'UpdateWorkspaceBase'
| 'MoveHunk'
| 'UpdateBranchName'
| 'UpdateBranchNotes'
| 'ReorderBranches'
| 'SelectDefaultVirtualBranch'
| 'UpdateBranchRemoteName'
| 'GenericBranchUpdate'
| 'DeleteBranch'
| 'ApplyBranch'
| 'DiscardHunk'
| 'DiscardFile'
| 'AmendCommit'
| 'UndoCommit'
| 'UnapplyBranch'
| 'CherryPick'
| 'SquashCommit'
| 'UpdateCommitMessage'
| 'MoveCommit'
| 'RestoreFromSnapshot'
| 'ReorderCommit'
| 'InsertBlankCommit'
| 'MoveCommitFile'
| 'FileChanges';
export type SnapshotDetails = {
title: string;
operation: Operation;
body: string | undefined;
trailers: Trailer[];
};
export type Snapshot = {
id: string;
linesAdded: number;
linesRemoved: number;
filesChanged: string[];
details: SnapshotDetails | undefined;
createdAt: number;
};
export function createdOnDay(dateNumber: number) {
const d = new Date(dateNumber);
const t = new Date();
return `${t.toDateString() == d.toDateString() ? 'Today' : d.toLocaleDateString('en-US', { weekday: 'short' })}, ${d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`;
}
export type SnapshotDiff = {
binary: boolean;
hunks: RemoteHunk[];
newPath: string;
newSizeBytes: number;
oldPath: string;
oldSizeBytes: number;
skipped: boolean;
};
</script>
<script lang="ts"> <script lang="ts">
import Button from './Button.svelte'; import Button from './Button.svelte';
import EmptyStatePlaceholder from './EmptyStatePlaceholder.svelte'; import EmptyStatePlaceholder from './EmptyStatePlaceholder.svelte';
@ -72,84 +7,29 @@
import ScrollableContainer from './ScrollableContainer.svelte'; import ScrollableContainer from './ScrollableContainer.svelte';
import SnapshotCard from './SnapshotCard.svelte'; import SnapshotCard from './SnapshotCard.svelte';
import emptyFolderSvg from '$lib/assets/empty-state/empty-folder.svg?raw'; import emptyFolderSvg from '$lib/assets/empty-state/empty-folder.svg?raw';
import { invoke, listen } from '$lib/backend/ipc'; import { listen } from '$lib/backend/ipc';
import { Project } from '$lib/backend/projects';
import { clickOutside } from '$lib/clickOutside'; import { clickOutside } from '$lib/clickOutside';
import { SETTINGS, type Settings } from '$lib/settings/userSettings'; import { HistoryService, createdOnDay } from '$lib/history/history';
import { getContext, getContextStoreBySymbol } from '$lib/utils/context'; import { persisted } from '$lib/persisted/persisted';
import { type RemoteHunk, RemoteFile } from '$lib/vbranches/types'; import { getContext } from '$lib/utils/context';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch'; import * as hotkeys from '$lib/utils/hotkeys';
import { RemoteFile } from '$lib/vbranches/types';
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { onMount, onDestroy } from 'svelte'; import { onMount } from 'svelte';
import type { Writable } from 'svelte/store'; import type { Snapshot, SnapshotDiff } from '$lib/history/types';
import { goto } from '$app/navigation';
const project = getContext(Project);
const historyService = getContext(HistoryService);
const snapshots = historyService.snapshots;
const showHistoryView = persisted(false, 'showHistoryView');
const loading = historyService.loading;
export let projectId: string;
let currentFilePreview: RemoteFile | undefined = undefined; let currentFilePreview: RemoteFile | undefined = undefined;
const userSettings = getContextStoreBySymbol<Settings, Writable<Settings>>(SETTINGS); async function onLastInView() {
if (!$loading) await historyService.loadMore();
const vbranchService = getContext(VirtualBranchService);
let listElement: HTMLElement | undefined = undefined;
// TODO: Fires multiple times nad cause uninitialized variable error
vbranchService.activeBranches.subscribe(() => {
// whenever virtual branches change, we need to reload the snapshots
// TODO: if the list has results from more pages, merge into it?
listSnapshots(projectId)
.then((rsp) => {
snapshots = rsp;
})
.catch((error) => {
console.error('Error occurred while listing snapshots:', error);
});
});
let snapshots: Snapshot[] = [];
let isSnapshotsLoading = false;
async function listSnapshots(projectId: string, sha?: string) {
isSnapshotsLoading = true;
const resp = await invoke<Snapshot[]>('list_snapshots', {
projectId: projectId,
limit: 32,
sha: sha
});
isSnapshotsLoading = false;
return resp;
}
async function getSnapshotDiff(projectId: string, sha: string) {
const resp = await invoke<{ [key: string]: SnapshotDiff }>('snapshot_diff', {
projectId: projectId,
sha: sha
});
return resp;
}
async function restoreSnapshot(projectId: string, sha: string) {
await invoke<string>('restore_snapshot', {
projectId: projectId,
sha: sha
});
await listSnapshots(projectId).then((rsp) => {
snapshots = rsp;
});
// TODO: is there a better way to update all the state?
await goto(window.location.href, { replaceState: true });
}
function onLastInView() {
if (!listElement) return;
if (listElement.scrollTop + listElement.clientHeight >= listElement.scrollHeight) {
listSnapshots(projectId, snapshots[snapshots.length - 1].id).then((rsp) => {
snapshots = [...snapshots, ...rsp.slice(1)];
});
}
} }
function updateFilePreview(entry: Snapshot, path: string) { function updateFilePreview(entry: Snapshot, path: string) {
@ -169,60 +49,35 @@
}); });
} }
function closeView() {
userSettings.update((s) => ({
...s,
showHistoryView: false
}));
}
onMount(async () => {
if (listElement) listElement.addEventListener('scroll', onLastInView, true);
});
onMount(() => { onMount(() => {
const unsubscribe = listen<string>('menu://view/history/clicked', () => { const unsubscribe = listen<string>('menu://view/history/clicked', () => {
userSettings.update((s) => ({ $showHistoryView = !$showHistoryView;
...s,
showHistoryView: !$userSettings.showHistoryView
}));
}); });
return () => { // TODO: Refactor somehow
const unsubscribeHotkeys = hotkeys.on('$mod+Shift+H', () => {
$showHistoryView = !$showHistoryView;
});
return async () => {
unsubscribe(); unsubscribe();
unsubscribeHotkeys();
}; };
}); });
onDestroy(() => {
listElement?.removeEventListener('scroll', onLastInView, true);
});
// optimisation: don't fetch snapshots if the view is not visible
$: if (!$userSettings.showHistoryView) {
snapshots = [];
currentFilePreview = undefined;
selectedFile = undefined;
} else {
listSnapshots(projectId).then((rsp) => {
snapshots = rsp;
});
}
let snapshotFilesTempStore: let snapshotFilesTempStore:
| { entryId: string; diffs: { [key: string]: SnapshotDiff } } | { entryId: string; diffs: { [key: string]: SnapshotDiff } }
| undefined = undefined; | undefined = undefined;
let selectedFile: { entryId: string; path: string } | undefined = undefined; let selectedFile: { entryId: string; path: string } | undefined = undefined;
$: if (snapshotFilesTempStore) {
console.log(snapshotFilesTempStore);
}
</script> </script>
{#if $userSettings.showHistoryView} {#if $showHistoryView}
<aside class="sideview-container" class:show-view={$userSettings.showHistoryView}> <aside class="sideview-container" class:show-view={$showHistoryView}>
<div <div
class="sideview-content-wrap" class="sideview-content-wrap"
use:clickOutside={{ use:clickOutside={{
handler: () => { handler: () => {
closeView(); $showHistoryView = false;
} }
}} }}
> >
@ -252,13 +107,13 @@
style="ghost" style="ghost"
icon="cross" icon="cross"
on:click={() => { on:click={() => {
closeView(); $showHistoryView = false;
}} }}
/> />
</div> </div>
<!-- EMPTY STATE --> <!-- EMPTY STATE -->
{#if snapshots.length == 0} {#if $snapshots.length == 0}
<EmptyStatePlaceholder image={emptyFolderSvg}> <EmptyStatePlaceholder image={emptyFolderSvg}>
<svelte:fragment slot="title">No snapshots yet</svelte:fragment> <svelte:fragment slot="title">No snapshots yet</svelte:fragment>
<svelte:fragment slot="caption"> <svelte:fragment slot="caption">
@ -268,17 +123,17 @@
</EmptyStatePlaceholder> </EmptyStatePlaceholder>
{/if} {/if}
{#if isSnapshotsLoading} {#if $snapshots.length == 0 && $loading}
<FullviewLoading /> <FullviewLoading />
{/if} {/if}
<!-- SNAPSHOTS --> <!-- SNAPSHOTS -->
{#if snapshots.length > 0 && !isSnapshotsLoading} {#if $snapshots.length > 0}
<ScrollableContainer on:bottomReached={onLastInView}> <ScrollableContainer on:bottomReached={onLastInView}>
<div class="container" bind:this={listElement}> <div class="container">
<!-- SNAPSHOTS FEED --> <!-- SNAPSHOTS FEED -->
{#each snapshots as entry, idx} {#each $snapshots as entry, idx (entry.id)}
{#if idx === 0 || createdOnDay(entry.createdAt) != createdOnDay(snapshots[idx - 1].createdAt)} {#if idx === 0 || createdOnDay(entry.createdAt) != createdOnDay($snapshots[idx - 1].createdAt)}
<div class="sideview__date-header"> <div class="sideview__date-header">
<h4 class="text-base-12 text-semibold"> <h4 class="text-base-12 text-semibold">
{createdOnDay(entry.createdAt)} {createdOnDay(entry.createdAt)}
@ -291,7 +146,7 @@
{entry} {entry}
isCurrent={idx == 0} isCurrent={idx == 0}
on:restoreClick={() => { on:restoreClick={() => {
restoreSnapshot(projectId, entry.id); historyService.restoreSnapshot(project.id, entry.id);
}} }}
{selectedFile} {selectedFile}
on:diffClick={async (filePath) => { on:diffClick={async (filePath) => {
@ -302,7 +157,7 @@
} else { } else {
snapshotFilesTempStore = { snapshotFilesTempStore = {
entryId: entry.id, entryId: entry.id,
diffs: await getSnapshotDiff(projectId, entry.id) diffs: await historyService.getSnapshotDiff(project.id, entry.id)
}; };
updateFilePreview(entry, path); updateFilePreview(entry, path);
} }

View File

@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import { createdOnDay } from './History.svelte';
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
import SnapshotAttachment from './SnapshotAttachment.svelte'; import SnapshotAttachment from './SnapshotAttachment.svelte';
import Tag from './Tag.svelte'; import Tag from './Tag.svelte';
import { getVSIFileIcon } from '$lib/ext-icons'; import { getVSIFileIcon } from '$lib/ext-icons';
import { createdOnDay } from '$lib/history/history';
import { toHumanReadableTime } from '$lib/utils/time';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import type { Snapshot, SnapshotDetails } from '$lib/history/types';
import type iconsJson from '$lib/icons/icons.json'; import type iconsJson from '$lib/icons/icons.json';
import type { Snapshot, SnapshotDetails } from './History.svelte';
export let entry: Snapshot; export let entry: Snapshot;
export let isCurrent: boolean = false; export let isCurrent: boolean = false;
@ -23,13 +24,9 @@
return `#${sha.slice(0, 7)}`; return `#${sha.slice(0, 7)}`;
} }
function createdAtTime(dateNumber: number) { function createdOnDayAndTime(epoch: number) {
const d = new Date(dateNumber); const date = new Date(epoch);
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); return `${createdOnDay(date)}, ${toHumanReadableTime(date)}`;
}
function createdOnDayAndTime(dateNumber: number) {
return `${createdOnDay(dateNumber)}, ${createdAtTime(dateNumber)}`;
} }
const dispatch = createEventDispatcher<{ restoreClick: void; diffClick: string }>(); const dispatch = createEventDispatcher<{ restoreClick: void; diffClick: string }>();
@ -160,7 +157,7 @@
class:restored-snapshot={isRestoreSnapshot} class:restored-snapshot={isRestoreSnapshot}
> >
<span class="snapshot-time text-base-12"> <span class="snapshot-time text-base-12">
{createdAtTime(entry.createdAt)} {toHumanReadableTime(entry.createdAt)}
</span> </span>
<div class="snapshot-line"> <div class="snapshot-line">

View File

@ -0,0 +1,60 @@
import { Snapshot, SnapshotDiff } from './types';
import { invoke } from '$lib/backend/ipc';
import { plainToInstance } from 'class-transformer';
import { writable } from 'svelte/store';
export class HistoryService {
cursor: string | undefined = undefined;
snapshots = writable<Snapshot[]>([], (set) => {
this.loadSnapshots().then((x) => set(x));
return () => {
set([]);
this.cursor = undefined;
};
});
loading = writable(false);
constructor(private projectId: string) {}
async loadSnapshots(after?: string) {
this.loading.set(true);
const resp = await invoke<Snapshot[]>('list_snapshots', {
projectId: this.projectId,
sha: after,
limit: 32
});
this.cursor = resp.length > 0 ? resp[resp.length - 1].id : undefined;
this.loading.set(false);
return plainToInstance(Snapshot, resp);
}
async loadMore() {
if (!this.cursor) throw new Error('Unable to load more without a cursor');
const more = await this.loadSnapshots(this.cursor);
this.snapshots.update((snapshots) => [...snapshots, ...more.slice(1)]);
}
async getSnapshotDiff(projectId: string, sha: string) {
const resp = await invoke<{ [key: string]: any }>('snapshot_diff', {
projectId: projectId,
sha: sha
});
return Object.entries(resp).reduce<{ [key: string]: SnapshotDiff }>((acc, [path, diff]) => {
acc[path] = plainToInstance(SnapshotDiff, diff);
return acc;
}, {});
}
async restoreSnapshot(projectId: string, sha: string) {
await invoke<string>('restore_snapshot', {
projectId: projectId,
sha: sha
});
}
}
export function createdOnDay(d: Date) {
const t = new Date();
return `${t.toDateString() == d.toDateString() ? 'Today' : d.toLocaleDateString('en-US', { weekday: 'short' })}, ${d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`;
}

View File

@ -0,0 +1,66 @@
import { RemoteHunk } from '$lib/vbranches/types';
import { Transform, Type } from 'class-transformer';
export type Operation =
| 'CreateCommit'
| 'CreateBranch'
| 'SetBaseBranch'
| 'MergeUpstream'
| 'UpdateWorkspaceBase'
| 'MoveHunk'
| 'UpdateBranchName'
| 'UpdateBranchNotes'
| 'ReorderBranches'
| 'SelectDefaultVirtualBranch'
| 'UpdateBranchRemoteName'
| 'GenericBranchUpdate'
| 'DeleteBranch'
| 'ApplyBranch'
| 'DiscardHunk'
| 'DiscardFile'
| 'AmendCommit'
| 'UndoCommit'
| 'UnapplyBranch'
| 'CherryPick'
| 'SquashCommit'
| 'UpdateCommitMessage'
| 'MoveCommit'
| 'RestoreFromSnapshot'
| 'ReorderCommit'
| 'InsertBlankCommit'
| 'MoveCommitFile'
| 'FileChanges';
export class Trailer {
key!: string;
value!: string;
}
export class SnapshotDiff {
binary!: boolean;
@Type(() => RemoteHunk)
hunks!: RemoteHunk[];
newPath!: string;
newSizeBytes!: number;
oldPath!: string;
oldSizeBytes!: number;
skipped!: boolean;
}
export class SnapshotDetails {
title!: string;
operation!: Operation;
body?: string | undefined;
@Type(() => Trailer)
trailers!: Trailer[];
}
export class Snapshot {
id!: string;
linesAdded!: number;
linesRemoved!: number;
filesChanged!: string[];
details?: SnapshotDetails;
@Transform((obj) => new Date(obj.value * 1000))
createdAt!: Date;
}

View File

@ -1,19 +1,19 @@
export async function on(combo: string, callback: (event: KeyboardEvent) => void) { import { tinykeys } from 'tinykeys';
export function on(combo: string, callback: (event: KeyboardEvent) => void) {
const comboContainsControlKeys = const comboContainsControlKeys =
combo.includes('Meta') || combo.includes('Alt') || combo.includes('Ctrl'); combo.includes('Meta') || combo.includes('Alt') || combo.includes('Ctrl');
return await import('tinykeys').then(({ tinykeys }) => return tinykeys(window, {
tinykeys(window, { [combo]: (event) => {
[combo]: (event) => { const target = event.target as HTMLElement;
const target = event.target as HTMLElement; const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA';
const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA'; if (isInput && !comboContainsControlKeys) return;
if (isInput && !comboContainsControlKeys) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
callback(event); callback(event);
} }
}) });
);
} }

View File

@ -1,12 +1,13 @@
export function toHumanReadableTime(timestamp: number) { export function toHumanReadableTime(d: Date) {
return new Date(timestamp).toLocaleTimeString('en-US', { return d.toLocaleTimeString('en-US', {
hour: 'numeric', hour: 'numeric',
minute: 'numeric' minute: 'numeric'
}); });
} }
export function toHumanReadableDate(timestamp: number) { export function toHumanReadableDate(d: Date) {
return new Date(timestamp).toLocaleDateString('en-US', { return d.toLocaleDateString('en-US', {
dateStyle: 'short' dateStyle: 'short',
hour12: false
}); });
} }

View File

@ -74,12 +74,6 @@
// This prevent backspace from navigating back // This prevent backspace from navigating back
e.preventDefault(); e.preventDefault();
}), }),
hotkeys.on('$mod+Shift+H', () => {
userSettings.update((s) => ({
...s,
showHistoryView: !$userSettings.showHistoryView
}));
}),
hotkeys.on('$mod+R', () => location.reload()) hotkeys.on('$mod+R', () => location.reload())
); );
}); });

View File

@ -7,6 +7,7 @@
import NotOnGitButlerBranch from '$lib/components/NotOnGitButlerBranch.svelte'; import NotOnGitButlerBranch from '$lib/components/NotOnGitButlerBranch.svelte';
import ProblemLoadingRepo from '$lib/components/ProblemLoadingRepo.svelte'; import ProblemLoadingRepo from '$lib/components/ProblemLoadingRepo.svelte';
import ProjectSettingsMenuAction from '$lib/components/ProjectSettingsMenuAction.svelte'; import ProjectSettingsMenuAction from '$lib/components/ProjectSettingsMenuAction.svelte';
import { HistoryService } from '$lib/history/history';
import { BaseBranchService, NoDefaultTarget } from '$lib/vbranches/baseBranch'; import { BaseBranchService, NoDefaultTarget } from '$lib/vbranches/baseBranch';
import { BranchController } from '$lib/vbranches/branchController'; import { BranchController } from '$lib/vbranches/branchController';
import { BaseBranch } from '$lib/vbranches/types'; import { BaseBranch } from '$lib/vbranches/types';
@ -33,6 +34,7 @@
$: projectError = projectService.error; $: projectError = projectService.error;
// const userSettings = getContextStoreBySymbol<Settings>(SETTINGS); // const userSettings = getContextStoreBySymbol<Settings>(SETTINGS);
$: setContext(HistoryService, data.historyService);
$: setContext(VirtualBranchService, vbranchService); $: setContext(VirtualBranchService, vbranchService);
$: setContext(BranchController, branchController); $: setContext(BranchController, branchController);
$: setContext(BranchService, branchService); $: setContext(BranchService, branchService);
@ -80,9 +82,7 @@
<div class="view-wrap" role="group" on:dragover|preventDefault> <div class="view-wrap" role="group" on:dragover|preventDefault>
<Navigation /> <Navigation />
<slot /> <slot />
<!-- {#if $userSettings.showHistoryView} --> <History />
<History {projectId} />
<!-- {/if} -->
</div> </div>
{/if} {/if}
{/key} {/key}

View File

@ -1,5 +1,6 @@
import { invoke } from '$lib/backend/ipc'; import { invoke } from '$lib/backend/ipc';
import { BranchService } from '$lib/branches/service'; import { BranchService } from '$lib/branches/service';
import { HistoryService } from '$lib/history/history';
import { getFetchNotifications } from '$lib/stores/fetches'; import { getFetchNotifications } from '$lib/stores/fetches';
import { getHeads } from '$lib/stores/head'; import { getHeads } from '$lib/stores/head';
import { RemoteBranchService } from '$lib/stores/remoteBranches'; import { RemoteBranchService } from '$lib/stores/remoteBranches';
@ -39,6 +40,7 @@ export async function load({ params, parent }) {
const heads$ = getHeads(projectId); const heads$ = getHeads(projectId);
const gbBranchActive$ = heads$.pipe(map((head) => head == 'gitbutler/integration')); const gbBranchActive$ = heads$.pipe(map((head) => head == 'gitbutler/integration'));
const historyService = new HistoryService(projectId);
const baseBranchService = new BaseBranchService(projectId, remoteUrl$, fetches$, heads$); const baseBranchService = new BaseBranchService(projectId, remoteUrl$, fetches$, heads$);
const vbranchService = new VirtualBranchService(projectId, gbBranchActive$); const vbranchService = new VirtualBranchService(projectId, gbBranchActive$);
@ -67,6 +69,7 @@ export async function load({ params, parent }) {
branchController, branchController,
branchService, branchService,
githubService, githubService,
historyService,
projectId, projectId,
project, project,
remoteBranchService, remoteBranchService,