display bookmark status in the sessions list

This commit is contained in:
Nikita Galaiko 2023-05-22 13:31:03 +02:00
parent f08fa0133b
commit beca54115b
12 changed files with 221 additions and 132 deletions

View File

@ -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"
}
}

View File

@ -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

View File

@ -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,

View File

@ -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)
}
}
}
}

View File

@ -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 });
});

View 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>

View File

@ -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';

View File

@ -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;
};

View File

@ -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}

View 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>

View File

@ -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>

View File

@ -44,6 +44,9 @@ const config = {
icon: {
default: '#A1A1AA'
},
bookmark: {
selected: '#2563EB'
},
white: '#FFFFFF',
transparent: 'transparent',
gray: {