v0.28.0 3cb3770

HistoryTimeline.svelte RawHistoryFolder

1<script lang="ts">
2  import type { TimelineGroup, HistoryEntry } from '../types';
3  import { homepageState } from '../state.svelte';
4  import HistoryItem from './HistoryItem.svelte';
5
6  interface Props {
7    groups: TimelineGroup[];
8    onSelectEntry?: (entry: HistoryEntry) => void;
9    onDeleteEntry?: (entry: HistoryEntry) => void;
10    onLoadMore?: () => void;
11  }
12
13  let { groups, onSelectEntry, onDeleteEntry, onLoadMore }: Props = $props();
14
15  let containerRef = $state<HTMLElement | null>(null);
16  let sentinelRef = $state<HTMLElement | null>(null);
17
18  // Flatten groups to get global index for keyboard navigation
19  const flatEntries = $derived(() => {
20    const entries: HistoryEntry[] = [];
21    for (const group of groups) {
22      entries.push(...group.entries);
23    }
24    return entries;
25  });
26
27  // Track which entry is focused based on global focusedIndex
28  const getFocusedEntry = () => {
29    const entries = flatEntries();
30    return entries[homepageState.focusedIndex] ?? null;
31  };
32
33  // Track previous focused index to only scroll on actual navigation
34  let prevFocusedIndex = $state(-1);
35
36  // Scroll focused item into view when focusedIndex changes via keyboard navigation
37  $effect(() => {
38    // Skip if panel not active or command palette is open
39    if (!containerRef || homepageState.activePanel !== 'history') return;
40    if (homepageState.commandPaletteOpen) return;
41
42    const currentIndex = homepageState.focusedIndex;
43
44    // Only scroll if index actually changed (not on initial render or modal close)
45    if (prevFocusedIndex === currentIndex) return;
46    if (prevFocusedIndex === -1) {
47      // First render - just track, don't scroll
48      prevFocusedIndex = currentIndex;
49      return;
50    }
51
52    prevFocusedIndex = currentIndex;
53
54    const focusedEntry = getFocusedEntry();
55    if (!focusedEntry) return;
56
57    // Find the focused element by entry ID
58    const el = containerRef.querySelector(`[data-entry-id="${focusedEntry.id}"]`);
59    if (el) {
60      el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
61    }
62  });
63
64  // Setup infinite scroll observer
65  $effect(() => {
66    if (!sentinelRef || !onLoadMore) return;
67
68    const observer = new IntersectionObserver(
69      (entries) => {
70        const entry = entries[0];
71        if (entry?.isIntersecting && homepageState.hasMoreHistory && !homepageState.historyLoading) {
72          onLoadMore();
73        }
74      },
75      { threshold: 0.1, rootMargin: '100px' }
76    );
77
78    observer.observe(sentinelRef);
79
80    return () => observer.disconnect();
81  });
82</script>
83
84<div class="history-timeline" bind:this={containerRef}>
85  {#if groups.length === 0}
86    <div class="empty-state">
87      <span class="empty-icon"></span>
88      <span class="empty-text">NO HISTORY RECORDED</span>
89      <span class="empty-hint">Start browsing to see your history here</span>
90    </div>
91  {:else}
92    {#each groups as group (group.date)}
93      <div class="timeline-group">
94        <div class="group-header">
95          <span class="group-label">{group.label}</span>
96          <span class="group-count">{group.entries.length}</span>
97        </div>
98        <div class="group-entries">
99          {#each group.entries as entry (entry.id)}
100            <HistoryItem
101              {entry}
102              focused={getFocusedEntry()?.id === entry.id}
103              onSelect={onSelectEntry}
104              onDelete={onDeleteEntry}
105            />
106          {/each}
107        </div>
108      </div>
109    {/each}
110
111    {#if homepageState.hasMoreHistory}
112      <div class="load-more-sentinel" bind:this={sentinelRef}>
113        {#if homepageState.historyLoading}
114          <span class="loading-text">LOADING...</span>
115        {/if}
116      </div>
117    {/if}
118  {/if}
119</div>
120
121<style>
122  .history-timeline {
123    display: flex;
124    flex-direction: column;
125  }
126
127  .empty-state {
128    flex: 1;
129    display: flex;
130    flex-direction: column;
131    align-items: center;
132    justify-content: center;
133    gap: 0.5rem;
134    padding: 2rem;
135    color: var(--muted-foreground);
136    text-align: center;
137  }
138
139  .empty-icon {
140    font-size: 1.5rem;
141    opacity: 0.5;
142  }
143
144  .empty-text {
145    font-size: 0.75rem;
146    font-weight: 600;
147    letter-spacing: 0.12em;
148  }
149
150  .empty-hint {
151    font-size: 0.7rem;
152    letter-spacing: 0.08em;
153    opacity: 0.7;
154  }
155
156  .timeline-group {
157    border-bottom-width: 1px;
158    border-bottom-style: solid;
159    border-bottom-color: var(--border);
160  }
161
162  .timeline-group:last-child {
163    border-bottom: none;
164  }
165
166  .group-header {
167    display: flex;
168    align-items: center;
169    justify-content: space-between;
170    gap: 0.75rem;
171    padding: 0.5rem 0.85rem;
172    background: color-mix(in srgb, var(--background) 80%, var(--card) 20%);
173    border-bottom-width: 1px;
174    border-bottom-style: solid;
175    border-bottom-color: color-mix(in srgb, var(--border) 50%, transparent);
176    position: sticky;
177    top: 0;
178    z-index: 1;
179  }
180
181  .group-label {
182    font-size: 0.68rem;
183    font-weight: 600;
184    text-transform: uppercase;
185    letter-spacing: 0.12em;
186    color: var(--foreground);
187  }
188
189  .group-count {
190    font-size: 0.6rem;
191    padding: 0.15rem 0.4rem;
192    border-width: 1px;
193    border-style: solid;
194    border-color: var(--border);
195    background: var(--background);
196    color: var(--muted-foreground);
197  }
198
199  .group-entries {
200    display: flex;
201    flex-direction: column;
202  }
203
204  .load-more-sentinel {
205    padding: 0.75rem;
206    display: flex;
207    align-items: center;
208    justify-content: center;
209    min-height: 40px;
210  }
211
212  .loading-text {
213    font-size: 0.65rem;
214    color: var(--muted-foreground);
215    letter-spacing: 0.12em;
216    animation: pulse 1.5s ease-in-out infinite;
217  }
218
219  @keyframes pulse {
220    0%, 100% { opacity: 0.4; }
221    50% { opacity: 1; }
222  }
223</style>

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close