v0.27.2 5960dd8

HistoryItem.svelte RawHistoryFolder

1<script lang="ts">
2  import type { HistoryEntry } from '../types';
3
4  interface Props {
5    entry: HistoryEntry;
6    focused?: boolean;
7    onSelect?: (entry: HistoryEntry) => void;
8    onDelete?: (entry: HistoryEntry) => void;
9  }
10
11  let { entry, focused = false, onSelect, onDelete }: Props = $props();
12
13  let deleting = $state(false);
14
15  const getDomain = (url: string): string => {
16    try {
17      return new URL(url).hostname;
18    } catch {
19      return url;
20    }
21  };
22
23  const getFaviconUrl = (item: HistoryEntry): string => {
24    // Use provided favicon_url if available
25    if (item.favicon_url) return item.favicon_url;
26    // Handle internal dumb:// URLs - use embedded favicon
27    if (item.url.startsWith('dumb://')) {
28      return 'favicon.png';
29    }
30    // Construct DuckDuckGo favicon URL from domain
31    const domain = getDomain(item.url).replace(/^www\./, '');
32    return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`;
33  };
34
35  const getDisplayTitle = (item: HistoryEntry): string => {
36    const trimmed = item.title?.trim();
37    if (trimmed) return trimmed;
38    return getDomain(item.url) || item.url;
39  };
40
41  const formatTime = (timestamp: string): string => {
42    try {
43      const date = new Date(timestamp);
44      const now = new Date();
45      const diff = now.getTime() - date.getTime();
46
47      const minutes = Math.floor(diff / (1000 * 60));
48      const hours = Math.floor(diff / (1000 * 60 * 60));
49
50      if (minutes < 1) return 'now';
51      if (minutes < 60) return `${minutes}m`;
52      if (hours < 24) return `${hours}h`;
53
54      return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
55    } catch {
56      return '';
57    }
58  };
59
60  const handleSelect = () => {
61    onSelect?.(entry);
62  };
63
64  const handleDelete = (e: Event) => {
65    e.stopPropagation();
66    deleting = true;
67    onDelete?.(entry);
68  };
69
70  const handleKeyDown = (e: KeyboardEvent) => {
71    if (e.key === 'Enter') {
72      handleSelect();
73    } else if (e.key === 'd' || e.key === 'x') {
74      handleDelete(e);
75    }
76  };
77</script>
78
79<div
80  class="history-item"
81  class:focused
82  class:deleting
83  role="button"
84  tabindex="0"
85  data-entry-id={entry.id}
86  onclick={handleSelect}
87  onkeydown={handleKeyDown}
88>
89  <div class="item-favicon">
90    <img
91      src={getFaviconUrl(entry)}
92      alt=""
93      class="favicon-img"
94      onerror={(e) => {
95        const target = e.target as HTMLImageElement;
96        target.style.display = 'none';
97        const fallback = target.nextElementSibling as HTMLElement;
98        if (fallback) fallback.style.display = 'flex';
99      }}
100    />
101    <div class="favicon-fallback" style="display: none;"></div>
102  </div>
103
104  <div class="item-content">
105    <div class="item-row">
106      <span class="item-title">{getDisplayTitle(entry)}</span>
107      <span class="item-time">{formatTime(entry.last_visited)}</span>
108    </div>
109    <div class="item-url">{entry.url}</div>
110  </div>
111
112  <div class="item-actions">
113    {#if entry.visit_count > 1}
114      <span class="visit-count">{entry.visit_count}x</span>
115    {/if}
116    <button
117      class="action-btn delete-btn"
118      type="button"
119      onclick={handleDelete}
120      title="Delete entry (d)"
121    >
122      <span aria-hidden="true">×</span>
123    </button>
124  </div>
125</div>
126
127<style>
128  .history-item {
129    display: grid;
130    grid-template-columns: auto 1fr auto;
131    align-items: center;
132    gap: 0.75rem;
133    padding: 0.55rem 0.85rem;
134    border-bottom-width: 1px;
135    border-bottom-style: solid;
136    border-bottom-color: color-mix(in srgb, var(--border) 50%, transparent);
137    transition: background-color 100ms ease;
138    cursor: pointer;
139  }
140
141  .history-item:last-child {
142    border-bottom: none;
143  }
144
145  .history-item:hover,
146  .history-item.focused {
147    background: color-mix(in srgb, var(--card) 40%, transparent);
148  }
149
150  .history-item.focused {
151    outline: 1px solid var(--primary, #4ade80);
152    outline-offset: -1px;
153  }
154
155  .history-item.deleting {
156    opacity: 0.3;
157    pointer-events: none;
158  }
159
160  .item-favicon {
161    width: 28px;
162    height: 28px;
163    border-width: 1px;
164    border-style: solid;
165    border-color: var(--border);
166    background: var(--background);
167    display: flex;
168    align-items: center;
169    justify-content: center;
170    flex-shrink: 0;
171  }
172
173  .favicon-img {
174    width: 16px;
175    height: 16px;
176    object-fit: contain;
177  }
178
179  .favicon-fallback {
180    width: 16px;
181    height: 16px;
182    display: flex;
183    align-items: center;
184    justify-content: center;
185    color: var(--muted-foreground);
186  }
187
188  .favicon-fallback::before {
189    content: '';
190    font-size: 0.75rem;
191  }
192
193  .item-content {
194    display: flex;
195    flex-direction: column;
196    gap: 0.15rem;
197    min-width: 0;
198  }
199
200  .item-row {
201    display: flex;
202    align-items: center;
203    justify-content: space-between;
204    gap: 0.75rem;
205  }
206
207  .item-title {
208    font-size: 0.8rem;
209    font-weight: 500;
210    color: var(--foreground);
211    white-space: nowrap;
212    overflow: hidden;
213    text-overflow: ellipsis;
214  }
215
216  .item-time {
217    font-size: 0.65rem;
218    color: var(--muted-foreground);
219    text-transform: uppercase;
220    letter-spacing: 0.08em;
221    white-space: nowrap;
222    flex-shrink: 0;
223  }
224
225  .item-url {
226    font-size: 0.68rem;
227    color: var(--muted-foreground);
228    white-space: nowrap;
229    overflow: hidden;
230    text-overflow: ellipsis;
231  }
232
233  .item-actions {
234    display: flex;
235    align-items: center;
236    gap: 0.5rem;
237  }
238
239  .visit-count {
240    font-size: 0.6rem;
241    color: var(--muted-foreground);
242    padding: 0.15rem 0.35rem;
243    border-width: 1px;
244    border-style: solid;
245    border-color: var(--border);
246    background: var(--background);
247  }
248
249  .action-btn {
250    width: 24px;
251    height: 24px;
252    display: flex;
253    align-items: center;
254    justify-content: center;
255    border-width: 1px;
256    border-style: solid;
257    border-color: var(--border);
258    background: transparent;
259    color: var(--muted-foreground);
260    cursor: pointer;
261    transition: all 100ms ease;
262    opacity: 0;
263  }
264
265  .history-item:hover .action-btn,
266  .history-item.focused .action-btn {
267    opacity: 1;
268  }
269
270  .action-btn:hover {
271    color: var(--foreground);
272    border-color: var(--foreground);
273    background: color-mix(in srgb, var(--card) 50%, transparent);
274  }
275
276  .delete-btn:hover {
277    color: var(--destructive);
278    border-color: var(--destructive);
279  }
280</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