mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-11-23 03:26:36 +03:00
display bookmark status in the sessions list
This commit is contained in:
parent
f08fa0133b
commit
beca54115b
@ -85,5 +85,8 @@
|
||||
"xterm-addon-ligatures": "^0.6.0",
|
||||
"xterm-addon-unicode11": "^0.5.0",
|
||||
"xterm-addon-webgl": "^0.14.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"svelte-loadable-store": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,10 @@
|
||||
lockfileVersion: '6.0'
|
||||
|
||||
dependencies:
|
||||
svelte-loadable-store:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
|
||||
devDependencies:
|
||||
'@codemirror/autocomplete':
|
||||
specifier: ^6.4.2
|
||||
@ -3649,6 +3654,12 @@ packages:
|
||||
svelte: 3.55.1
|
||||
dev: true
|
||||
|
||||
/svelte-loadable-store@1.0.0:
|
||||
resolution: {integrity: sha512-HtCaU1k+tyjf0uDrMlNXzyXdOBZdsQJ1oJ+UDrEQJDbcmEr9W/FkvIIagDbAnriylgI8GUiQtFibI5UyR3rbDg==}
|
||||
dependencies:
|
||||
svelte: 3.59.1
|
||||
dev: false
|
||||
|
||||
/svelte-preprocess@5.0.1(postcss-load-config@4.0.1)(postcss@8.4.21)(svelte@3.55.1)(typescript@4.9.5):
|
||||
resolution: {integrity: sha512-0HXyhCoc9rsW4zGOgtInylC6qj259E1hpFnJMJWTf+aIfeqh4O/QHT31KT2hvPEqQfdjmqBR/kO2JDkkciBLrQ==}
|
||||
engines: {node: '>= 14.10.0'}
|
||||
@ -3718,6 +3729,11 @@ packages:
|
||||
engines: {node: '>= 8'}
|
||||
dev: true
|
||||
|
||||
/svelte@3.59.1:
|
||||
resolution: {integrity: sha512-pKj8fEBmqf6mq3/NfrB9SLtcJcUvjYSWyePlfCqN9gujLB25RitWK8PvFzlwim6hD/We35KbPlRteuA6rnPGcQ==}
|
||||
engines: {node: '>= 8'}
|
||||
dev: false
|
||||
|
||||
/symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
dev: true
|
||||
|
@ -1,7 +1,7 @@
|
||||
use anyhow::{Context, Result};
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::{deltas, sessions};
|
||||
use crate::{bookmarks, deltas, sessions};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Sender {
|
||||
@ -65,6 +65,13 @@ impl Event {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bookmark(project_id: &str, bookmark: &bookmarks::Bookmark) -> Self {
|
||||
Event {
|
||||
name: format!("project://{}/bookmarks", project_id),
|
||||
payload: serde_json::to_value(bookmark).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deltas(
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
|
@ -178,10 +178,16 @@ impl<'handler> Handler<'handler> {
|
||||
.context("failed to send deltas event")?;
|
||||
Ok(delta_events)
|
||||
}
|
||||
events::Event::Bookmark(bookmark) => self
|
||||
.index_handler
|
||||
.index_bookmark(&bookmark)
|
||||
.context("failed to index bookmark"),
|
||||
events::Event::Bookmark(bookmark) => {
|
||||
let bookmarks_events = self
|
||||
.index_handler
|
||||
.index_bookmark(&bookmark)
|
||||
.context("failed to index bookmark")?;
|
||||
self.events_sender
|
||||
.send(app_events::Event::bookmark(&self.project_id, &bookmark))
|
||||
.context("failed to send bookmark event")?;
|
||||
Ok(bookmarks_events)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { invoke } from '$lib/ipc';
|
||||
import { invoke, listen } from '$lib/ipc';
|
||||
|
||||
export type Bookmark = {
|
||||
projectId: string;
|
||||
@ -21,3 +21,17 @@ export const list = (params: {
|
||||
end: number;
|
||||
};
|
||||
}) => invoke<Bookmark[]>('list_bookmarks', params);
|
||||
|
||||
export const subscribe = (
|
||||
params: { projectId: string; range?: { start: number; end: number } },
|
||||
callback: (bookmark: Bookmark) => Promise<void> | void
|
||||
) =>
|
||||
listen<Bookmark>(`project://${params.projectId}/bookmarks`, (event) => {
|
||||
if (
|
||||
params.range &&
|
||||
(event.payload.timestampMs < params.range.start ||
|
||||
event.payload.timestampMs >= params.range.end)
|
||||
)
|
||||
return;
|
||||
callback({ ...params, ...event.payload });
|
||||
});
|
||||
|
22
src/lib/icons/IconBookmarkFilled.svelte
Normal file
22
src/lib/icons/IconBookmarkFilled.svelte
Normal file
@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
let className = '';
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class={className}
|
||||
width="14"
|
||||
height="16"
|
||||
viewBox="0 0 14 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11.4744 1.25161C12.3544 1.35116 13 2.0892 13 2.95091V15L7 12.0836L1 15V2.95091C1 2.0892 1.6448 1.35116 2.5256 1.25161C5.49855 0.916131 8.50145 0.916131 11.4744 1.25161Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
@ -25,3 +25,4 @@ export { default as IconArrowRight } from './IconArrowRight.svelte';
|
||||
export { default as IconBookmark } from './IconBookmark.svelte';
|
||||
export { default as IconFolder } from './IconFolder.svelte';
|
||||
export { default as IconEmail } from './IconEmail.svelte';
|
||||
export { default as IconBookmarkFilled } from './IconBookmarkFilled.svelte';
|
||||
|
@ -1,41 +1,16 @@
|
||||
import { asyncWritable, type Loadable } from '@square/svelte-store';
|
||||
import { type Bookmark, bookmarks } from '$lib/api';
|
||||
import { writable, type Loadable } from 'svelte-loadable-store';
|
||||
import { bookmarks, type Bookmark } from '$lib/api';
|
||||
import type { Readable } from 'svelte/store';
|
||||
|
||||
export type Store = Loadable<Bookmark[]> & {
|
||||
create: (params?: { note?: string; timestampMs?: number }) => Promise<Bookmark>;
|
||||
};
|
||||
const stores: Record<string, Readable<Loadable<Bookmark[]>>> = {};
|
||||
|
||||
const stores: Record<string, Store> = {};
|
||||
export default (params: { projectId: string }) => {
|
||||
if (params.projectId in stores) return stores[params.projectId];
|
||||
|
||||
export default (params: { projectId: string }): Store => {
|
||||
const { projectId } = params;
|
||||
if (projectId in stores) {
|
||||
return stores[projectId];
|
||||
}
|
||||
const store = asyncWritable<[], Bookmark[]>(
|
||||
[],
|
||||
() => bookmarks.list(params),
|
||||
async (newValue, _parents, oldValue) => {
|
||||
const changedBookmarks = newValue.filter((bookmark) => {
|
||||
const oldBookmark = oldValue?.find((b) => b.timestampMs === bookmark.timestampMs);
|
||||
if (!oldBookmark) return true;
|
||||
return oldBookmark !== bookmark;
|
||||
});
|
||||
await Promise.all(changedBookmarks.map((bookmark) => bookmarks.upsert(bookmark)));
|
||||
return newValue;
|
||||
}
|
||||
const { subscribe } = writable(bookmarks.list(params), (set) =>
|
||||
bookmarks.subscribe(params, () => bookmarks.list(params).then(set))
|
||||
);
|
||||
return {
|
||||
...store,
|
||||
create: async ({ timestampMs, note }: { note?: string; timestampMs?: number } = {}) => {
|
||||
const newBookmark = {
|
||||
projectId,
|
||||
timestampMs: timestampMs ?? Date.now(),
|
||||
note: note ?? '',
|
||||
deleted: false
|
||||
};
|
||||
await store.update((value) => [...value, newBookmark]);
|
||||
return newBookmark;
|
||||
}
|
||||
};
|
||||
const store = { subscribe };
|
||||
stores[params.projectId] = store;
|
||||
return store;
|
||||
};
|
||||
|
@ -1,40 +1,20 @@
|
||||
<script lang="ts" context="module">
|
||||
import { deltas, type Session, type Delta } from '$lib/api';
|
||||
const enrichSession = async (projectId: string, session: Session, paths?: string[]) => {
|
||||
const sessionDeltas = await deltas
|
||||
.list({ projectId, sessionId: session.id, paths })
|
||||
.then((deltas) =>
|
||||
Object.entries(deltas)
|
||||
.flatMap(([path, deltas]) => deltas.map((delta) => [path, delta] as [string, Delta]))
|
||||
.sort((a, b) => a[1].timestampMs - b[1].timestampMs)
|
||||
);
|
||||
return {
|
||||
...session,
|
||||
deltas: sessionDeltas
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { LayoutData } from './$types';
|
||||
import { IconChevronLeft, IconChevronRight } from '$lib/icons';
|
||||
import { collapse } from '$lib/paths';
|
||||
import { page } from '$app/stores';
|
||||
import { asyncDerived, derived } from '@square/svelte-store';
|
||||
import { format } from 'date-fns';
|
||||
import { onMount } from 'svelte';
|
||||
import { events, hotkeys, stores, toasts } from '$lib';
|
||||
import { api, events, hotkeys, toasts } from '$lib';
|
||||
import BookmarkModal from './BookmarkModal.svelte';
|
||||
import tinykeys from 'tinykeys';
|
||||
import { goto } from '$app/navigation';
|
||||
import { unsubscribe } from '$lib/utils';
|
||||
import SessionCard from './SessionCard.svelte';
|
||||
|
||||
export let data: LayoutData;
|
||||
const { currentFilepath, currentTimestamp } = data;
|
||||
|
||||
const unique = (value: any, index: number, self: any[]) => self.indexOf(value) === index;
|
||||
const lexically = (a: string, b: string) => a.localeCompare(b);
|
||||
|
||||
const dateSessions = derived([data.sessions, page], ([sessions, page]) =>
|
||||
sessions?.filter(
|
||||
(session) => format(session.meta.startTimestampMs, 'yyyy-MM-dd') === page.params.date
|
||||
@ -47,12 +27,16 @@
|
||||
const richSessions = asyncDerived(
|
||||
[dateSessions, fileFilter, projectId],
|
||||
async ([sessions, fileFilter, projectId]) => {
|
||||
const paths = fileFilter ? [fileFilter] : undefined;
|
||||
const richSessions = await Promise.all(
|
||||
sessions.map((s) => enrichSession(projectId, s, paths))
|
||||
);
|
||||
return richSessions
|
||||
.filter((s) => s.deltas.length > 0)
|
||||
return sessions
|
||||
.map((session) => ({
|
||||
...session,
|
||||
deltas: derived(api.deltas.Deltas({ projectId, sessionId: session.id }), (deltas) => {
|
||||
if (!fileFilter) return deltas;
|
||||
return Object.fromEntries(
|
||||
Object.entries(deltas).filter(([path]) => fileFilter.includes(path))
|
||||
);
|
||||
})
|
||||
}))
|
||||
.sort((a, b) => a.meta.startTimestampMs - b.meta.startTimestampMs);
|
||||
}
|
||||
);
|
||||
@ -90,25 +74,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
const sessionRange = (session: Session) => {
|
||||
const day = new Date(session.meta.startTimestampMs).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
const start = new Date(session.meta.startTimestampMs).toLocaleString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric'
|
||||
});
|
||||
const end = new Date(session.meta.lastTimestampMs).toLocaleString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric'
|
||||
});
|
||||
return `${day} ${start} - ${end}`;
|
||||
};
|
||||
|
||||
const sessionDuration = (session: Session) =>
|
||||
`${Math.round((session.meta.lastTimestampMs - session.meta.startTimestampMs) / 1000 / 60)} min`;
|
||||
|
||||
const removeFromSearchParams = (params: URLSearchParams, key: string) => {
|
||||
params.delete(key);
|
||||
return params;
|
||||
@ -137,9 +102,13 @@
|
||||
events.on('openBookmarkModal', () => bookmarkModal?.show($currentTimestamp)),
|
||||
hotkeys.on('Meta+Shift+D', () => bookmarkModal?.show($currentTimestamp)),
|
||||
hotkeys.on('D', () =>
|
||||
stores
|
||||
.bookmarks({ projectId: $projectId })
|
||||
.create({ timestampMs: $currentTimestamp })
|
||||
api.bookmarks
|
||||
.upsert({
|
||||
projectId: $projectId,
|
||||
note: '',
|
||||
timestampMs: $currentTimestamp,
|
||||
deleted: false
|
||||
})
|
||||
.then(() => toasts.success('Bookmark created'))
|
||||
)
|
||||
)
|
||||
@ -172,44 +141,12 @@
|
||||
>
|
||||
{#each $richSessions as session}
|
||||
{@const isCurrent = session.id === $currentSession?.id}
|
||||
{@const filesChagned = new Set(session.deltas.map(([path]) => path)).size}
|
||||
<li
|
||||
id={isCurrent ? 'current-session' : ''}
|
||||
class:bg-card-active={isCurrent}
|
||||
class="session-card rounded border-[0.5px] border-gb-700 text-zinc-300 shadow-md"
|
||||
>
|
||||
<a href={getSessionURI(session.id)} class:pointer-events-none={isCurrent} class="w-full">
|
||||
<div class="flex flex-row justify-between rounded-t px-3 pt-3">
|
||||
<span>{sessionRange(session)}</span>
|
||||
<span>{sessionDuration(session)}</span>
|
||||
</div>
|
||||
|
||||
<span class="flex flex-row justify-between px-3 pb-3">
|
||||
{filesChagned}
|
||||
{filesChagned !== 1 ? 'files' : 'file'}
|
||||
</span>
|
||||
|
||||
{#if isCurrent}
|
||||
<ul
|
||||
class="list-disk list-none overflow-hidden rounded-bl rounded-br bg-zinc-800 py-1 pl-0 pr-2"
|
||||
style:list-style="disc"
|
||||
>
|
||||
{#each session.deltas
|
||||
.map((d) => d[0])
|
||||
.filter(unique)
|
||||
.sort(lexically) as filename}
|
||||
<li
|
||||
class:text-zinc-100={$currentFilepath === filename}
|
||||
class:bg-[#3356C2]={$currentFilepath === filename}
|
||||
class="mx-5 ml-1 w-full list-none rounded p-1 text-zinc-500"
|
||||
>
|
||||
{collapse(filename)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</a>
|
||||
</li>
|
||||
<SessionCard
|
||||
{isCurrent}
|
||||
{session}
|
||||
deltas={session.deltas}
|
||||
currentFilepath={$currentFilepath}
|
||||
/>
|
||||
{:else}
|
||||
<div class="mt-4 text-center text-zinc-300">No activities found</div>
|
||||
{/each}
|
||||
|
107
src/routes/projects/[projectId]/player/[date]/SessionCard.svelte
Normal file
107
src/routes/projects/[projectId]/player/[date]/SessionCard.svelte
Normal file
@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import type { Delta, Session } from '$lib/api';
|
||||
import { page } from '$app/stores';
|
||||
import { collapse } from '$lib/paths';
|
||||
import { derived, type Loadable } from '@square/svelte-store';
|
||||
import { stores } from '$lib';
|
||||
import { IconBookmarkFilled } from '$lib/icons';
|
||||
|
||||
export let isCurrent: boolean;
|
||||
export let session: Session;
|
||||
export let currentFilepath: string;
|
||||
export let deltas: Loadable<Record<string, Delta[]>>;
|
||||
|
||||
$: bookmarks = derived(
|
||||
[stores.bookmarks({ projectId: session.projectId }), deltas],
|
||||
([bookmarks, deltas]) => {
|
||||
if (bookmarks.isLoading) return [];
|
||||
const timestamps = Object.values(deltas ?? {}).flatMap((deltas) =>
|
||||
deltas.map((d) => d.timestampMs)
|
||||
);
|
||||
const start = Math.min(...timestamps);
|
||||
const end = Math.max(...timestamps);
|
||||
return bookmarks.value.filter(
|
||||
(bookmark) => bookmark.timestampMs >= start && bookmark.timestampMs < end
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const unique = (value: any, index: number, self: any[]) => self.indexOf(value) === index;
|
||||
const lexically = (a: string, b: string) => a.localeCompare(b);
|
||||
|
||||
const changedFiles = derived(deltas, (deltas) => Object.keys(deltas ?? {}).filter(unique));
|
||||
|
||||
const sessionDuration = (session: Session) =>
|
||||
`${Math.round((session.meta.lastTimestampMs - session.meta.startTimestampMs) / 1000 / 60)} min`;
|
||||
|
||||
const sessionRange = (session: Session) => {
|
||||
const day = new Date(session.meta.startTimestampMs).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
const start = new Date(session.meta.startTimestampMs).toLocaleString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric'
|
||||
});
|
||||
const end = new Date(session.meta.lastTimestampMs).toLocaleString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric'
|
||||
});
|
||||
return `${day} ${start} - ${end}`;
|
||||
};
|
||||
|
||||
const removeFromSearchParams = (params: URLSearchParams, key: string) => {
|
||||
params.delete(key);
|
||||
return params;
|
||||
};
|
||||
|
||||
const getSessionURI = (sessionId: string) =>
|
||||
`/projects/${$page.params.projectId}/player/${
|
||||
$page.params.date
|
||||
}/${sessionId}?${removeFromSearchParams($page.url.searchParams, 'delta').toString()}`;
|
||||
</script>
|
||||
|
||||
<li
|
||||
id={isCurrent ? 'current-session' : ''}
|
||||
class:bg-card-active={isCurrent}
|
||||
class="session-card relative rounded border-[0.5px] border-gb-700 text-zinc-300 shadow-md"
|
||||
>
|
||||
{#await bookmarks.load() then}
|
||||
{#if $bookmarks?.length > 0}
|
||||
<div class="absolute top-0 right-5 flex gap-2 overflow-hidden text-bookmark-selected">
|
||||
<IconBookmarkFilled class="-mt-1 h-4 w-4" />
|
||||
</div>
|
||||
{/if}
|
||||
{/await}
|
||||
|
||||
<a href={getSessionURI(session.id)} class:pointer-events-none={isCurrent} class="w-full">
|
||||
<div class="flex flex-row justify-between rounded-t px-3 pt-3">
|
||||
<span>{sessionRange(session)}</span>
|
||||
<span>{sessionDuration(session)}</span>
|
||||
</div>
|
||||
|
||||
<span class="flex flex-row justify-between px-3 pb-3">
|
||||
{$changedFiles.length}
|
||||
{$changedFiles.length !== 1 ? 'files' : 'file'}
|
||||
</span>
|
||||
|
||||
{#if isCurrent}
|
||||
{#await changedFiles.load() then}
|
||||
<ul
|
||||
class="list-disk list-none overflow-hidden rounded-bl rounded-br bg-zinc-800 py-1 pl-0 pr-2"
|
||||
style:list-style="disc"
|
||||
>
|
||||
{#each $changedFiles.sort(lexically) as filename}
|
||||
<li
|
||||
class:text-zinc-100={currentFilepath === filename}
|
||||
class:bg-[#3356C2]={currentFilepath === filename}
|
||||
class="mx-5 ml-1 w-full list-none rounded p-1 text-zinc-500"
|
||||
>
|
||||
{collapse(filename)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/await}
|
||||
{/if}
|
||||
</a>
|
||||
</li>
|
@ -241,8 +241,6 @@
|
||||
{collapse($frame.filepath)}
|
||||
</span>
|
||||
<span class="whitespace-nowrap text-zinc-500">
|
||||
–
|
||||
{currentDelta.timestampMs}
|
||||
–
|
||||
{new Date(currentDelta.timestampMs).toLocaleString('en-US')}
|
||||
</span>
|
||||
|
@ -44,6 +44,9 @@ const config = {
|
||||
icon: {
|
||||
default: '#A1A1AA'
|
||||
},
|
||||
bookmark: {
|
||||
selected: '#2563EB'
|
||||
},
|
||||
white: '#FFFFFF',
|
||||
transparent: 'transparent',
|
||||
gray: {
|
||||
|
Loading…
Reference in New Issue
Block a user