v0.20.1 d9a391f

HomepageShell.svelte RawHistoryFolder

1<script lang="ts">
2  import { onMount, onDestroy } from 'svelte';
3  import { homepageState } from '../state.svelte';
4  import { initializeHomepage } from '../messaging';
5  import { initializeKeyboard } from '../keyboard';
6  import CommandPalette from './CommandPalette.svelte';
7  import StatusBar from './StatusBar.svelte';
8  import KeyboardHints from './KeyboardHints.svelte';
9  import { History, Star, BarChart3, Search, Moon, Sun, Settings } from '@lucide/svelte';
10
11  // Props for slot content
12  interface Props {
13    children?: import('svelte').Snippet;
14  }
15  let { children }: Props = $props();
16
17  // Theme state
18  type ThemeMode = 'light' | 'dark';
19  let themeMode = $state<ThemeMode>('dark');
20  let themeObserver: MutationObserver | null = null;
21
22  // Keyboard cleanup
23  let cleanupKeyboard: (() => void) | null = null;
24
25  // Panel tabs config
26  const panelTabs = [
27    { id: 'history' as const, label: 'HIST', icon: History },
28    { id: 'favorites' as const, label: 'FAV', icon: Star },
29    { id: 'analytics' as const, label: 'STAT', icon: BarChart3 },
30  ];
31
32  // Theme management
33  const syncThemeState = () => {
34    themeMode = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
35  };
36
37  function navigateWithViewTransition(url: string) {
38    const doc = document as any;
39
40    if (typeof doc?.startViewTransition !== 'function') {
41      window.location.href = url;
42      return;
43    }
44
45    const transition = doc.startViewTransition(() => {
46      document.documentElement.dataset.vt = 'leaving';
47    });
48
49    transition.finished.finally(() => {
50      window.location.href = url;
51    });
52  }
53
54  const toggleTheme = () => {
55    const nextMode = themeMode === 'dark' ? 'light' : 'dark';
56    const manager = (window as any).__dumber_color_scheme_manager as
57      | { setUserPreference?: (theme: ThemeMode) => void }
58      | undefined;
59
60    if (manager?.setUserPreference) {
61      manager.setUserPreference(nextMode);
62      return;
63    }
64
65    if ((window as any).__dumber_setTheme) {
66      (window as any).__dumber_setTheme(nextMode);
67      localStorage.setItem('dumber.theme', nextMode);
68      return;
69    }
70
71    switch (nextMode) {
72      case 'light':
73        document.documentElement.classList.add('light');
74        document.documentElement.classList.remove('dark');
75        break;
76      case 'dark':
77        document.documentElement.classList.remove('light');
78        document.documentElement.classList.add('dark');
79        break;
80    }
81    localStorage.setItem('dumber.theme', nextMode);
82    themeMode = nextMode;
83  };
84
85  onMount(async () => {
86    // Initialize keyboard navigation
87    cleanupKeyboard = initializeKeyboard();
88
89    // Initialize data fetching
90    await initializeHomepage();
91
92    // Theme synchronization
93    syncThemeState();
94    themeObserver = new MutationObserver(syncThemeState);
95    themeObserver.observe(document.documentElement, {
96      attributes: true,
97      attributeFilter: ['class'],
98    });
99  });
100
101  onDestroy(() => {
102    cleanupKeyboard?.();
103    themeObserver?.disconnect();
104    themeObserver = null;
105    homepageState.reset();
106  });
107</script>
108
109<svelte:head>
110  <title>dumb://home</title>
111  <meta name="description" content="Dumber Browser - Homepage" />
112  {@html `<style>
113    html, body { margin: 0; padding: 0; }
114    html { background: var(--background, #0a0a0a); }
115    body { background: var(--background, #0a0a0a); color: var(--foreground, #e5e5e5); }
116    /* Disable GTK/WebKit default focus rings - we handle focus styling ourselves */
117    *:focus { outline: none !important; }
118    *:focus-visible { outline: none !important; }
119    button:focus, input:focus, a:focus { outline: none !important; box-shadow: none !important; }
120    /* Enable touch scrolling for WebKit */
121    * { -webkit-overflow-scrolling: touch; }
122  </style>`}
123</svelte:head>
124
125<div class="homepage-shell">
126  <!-- Terminal Frame -->
127  <div class="terminal-frame">
128    <!-- Header -->
129    <header class="terminal-header">
130      <div class="header-left">
131        <span class="terminal-path">dumb://home</span>
132        <div class="header-tabs" role="tablist" aria-label="Homepage panels">
133          {#each panelTabs as tab, index (tab.id)}
134            <button
135              class="tab-button"
136              class:active={homepageState.activePanel === tab.id}
137              onclick={() => homepageState.setActivePanel(tab.id)}
138              onkeydown={(e) => {
139                switch (e.key) {
140                  case 'Enter':
141                  case ' ':
142                    e.preventDefault();
143                    homepageState.setActivePanel(tab.id);
144                    break;
145                  case 'ArrowLeft':
146                  case 'ArrowUp':
147                    e.preventDefault();
148                    {
149                      const prevIndex = index === 0 ? panelTabs.length - 1 : index - 1;
150                      const prevTab = panelTabs[prevIndex];
151                      if (prevTab) {
152                        homepageState.setActivePanel(prevTab.id);
153                        (e.currentTarget.parentElement?.children[prevIndex] as HTMLElement)?.focus();
154                      }
155                    }
156                    break;
157                  case 'ArrowRight':
158                  case 'ArrowDown':
159                    e.preventDefault();
160                    {
161                      const nextIndex = index === panelTabs.length - 1 ? 0 : index + 1;
162                      const nextTab = panelTabs[nextIndex];
163                      if (nextTab) {
164                        homepageState.setActivePanel(nextTab.id);
165                        (e.currentTarget.parentElement?.children[nextIndex] as HTMLElement)?.focus();
166                      }
167                    }
168                    break;
169                }
170              }}
171              type="button"
172              role="tab"
173              aria-selected={homepageState.activePanel === tab.id}
174              tabindex={homepageState.activePanel === tab.id ? 0 : -1}
175            >
176              <tab.icon size={14} strokeWidth={2} class="tab-icon" />
177              <span class="tab-label">{tab.label}</span>
178            </button>
179          {/each}
180        </div>
181      </div>
182      <div class="header-right">
183        <button
184          class="action-button"
185          type="button"
186          onclick={() => homepageState.openCommandPalette()}
187          title="Command Palette (Ctrl+P or /)"
188        >
189          <Search size={14} strokeWidth={2} />
190          <span class="button-text">CMD</span>
191          <kbd class="kbd-hint">/</kbd>
192        </button>
193        <button
194          class="action-button"
195          type="button"
196          onclick={() => navigateWithViewTransition('dumb://config')}
197          title="Settings"
198        >
199          <Settings size={14} strokeWidth={2} />
200          <span class="button-text">CFG</span>
201        </button>
202        <button
203          class="theme-toggle"
204          type="button"
205          onclick={toggleTheme}
206          title={themeMode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
207          aria-pressed={themeMode === 'dark'}
208        >
209          {#if themeMode === 'dark'}
210            <Moon size={18} strokeWidth={2} />
211          {:else}
212            <Sun size={18} strokeWidth={2} />
213          {/if}
214        </button>
215      </div>
216    </header>
217
218    <!-- Main Content Area -->
219    <main class="terminal-body scrollable">
220      {#if children}
221        {@render children()}
222      {:else}
223        <div class="empty-shell">
224          <span class="shell-prompt">$</span>
225          <span class="shell-cursor">_</span>
226        </div>
227      {/if}
228    </main>
229
230    <!-- Status Bar -->
231    <StatusBar />
232  </div>
233
234  <!-- Keyboard Hints Overlay -->
235  <KeyboardHints />
236
237  <!-- Command Palette Modal -->
238  {#if homepageState.commandPaletteOpen}
239    <CommandPalette />
240  {/if}
241
242  <!-- Confirmation Modal -->
243  {#if homepageState.confirmModalOpen}
244    <div
245      class="modal-overlay"
246      onclick={() => homepageState.cancelConfirm()}
247      onkeydown={(e) => { if (e.key === 'Escape') homepageState.cancelConfirm(); }}
248      role="button"
249      tabindex="0"
250      aria-label="Close confirmation modal"
251    >
252      <div class="confirm-modal" onclick={(e) => e.stopPropagation()} role="presentation">
253        <div class="modal-header">
254          <span class="modal-icon"></span>
255          <span class="modal-title">CONFIRM</span>
256        </div>
257        <p class="modal-message">{homepageState.confirmModalMessage}</p>
258        <div class="modal-actions">
259          <button
260            class="modal-btn modal-btn-cancel"
261            type="button"
262            onclick={() => homepageState.cancelConfirm()}
263          >
264            CANCEL
265            <kbd>Esc</kbd>
266          </button>
267          <button
268            class="modal-btn modal-btn-confirm"
269            type="button"
270            onclick={() => homepageState.confirmAction()}
271          >
272            CONFIRM
273            <kbd>Enter</kbd>
274          </button>
275        </div>
276      </div>
277    </div>
278  {/if}
279</div>
280
281<style>
282  :global(html),
283  :global(body) {
284    height: 100%;
285  }
286
287  :global(body) {
288    overflow: hidden;
289    overscroll-behavior: contain;
290  }
291
292  .homepage-shell {
293    height: 100vh;
294    min-height: 100vh;
295    display: flex;
296    flex-direction: column;
297    font-family: ui-monospace, 'Fira Code', 'Cascadia Code', Menlo, Monaco, Consolas, monospace;
298    line-height: 1.5;
299    color: var(--foreground);
300    background: var(--background);
301    overflow: hidden;
302  }
303
304  .terminal-frame {
305    flex: 1;
306    display: grid;
307    grid-template-rows: auto 1fr auto;
308    width: 100%;
309    height: 100%;
310    background: color-mix(in srgb, var(--card) 60%, var(--background) 40%);
311  }
312
313  /* Dark mode: subtle shadow */
314  :global(.dark) .terminal-frame {
315    box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.5);
316  }
317
318  /* Header */
319  .terminal-header {
320    display: flex;
321    align-items: center;
322    justify-content: space-between;
323    gap: 1rem;
324    padding: 0.75rem 1rem;
325    border-bottom: 1px solid var(--border);
326    background: color-mix(in srgb, var(--background) 90%, var(--card) 10%);
327    position: sticky;
328    top: 0;
329    z-index: 5;
330  }
331
332  .header-left {
333    display: flex;
334    align-items: center;
335    gap: 1.5rem;
336  }
337
338  .terminal-path {
339    font-size: 0.8rem;
340    font-weight: 600;
341    color: var(--primary, #4ade80);
342    letter-spacing: 0.05em;
343  }
344
345  .header-tabs {
346    display: flex;
347    gap: 0.25rem;
348  }
349
350  .tab-button {
351    display: flex;
352    align-items: center;
353    gap: 0.4rem;
354    padding: 0.4rem 0.75rem;
355    font-size: 0.7rem;
356    font-weight: 500;
357    letter-spacing: 0.1em;
358    color: var(--muted-foreground);
359    background: transparent;
360    border: 1px solid transparent;
361    cursor: pointer;
362    transition: all 120ms ease;
363  }
364
365  .tab-button:hover {
366    color: var(--foreground);
367    background: color-mix(in srgb, var(--card) 50%, transparent);
368  }
369
370  .tab-button:focus-visible {
371    color: var(--foreground);
372    border-color: var(--primary, #4ade80);
373    background: color-mix(in srgb, var(--primary, #4ade80) 15%, transparent);
374  }
375
376  .tab-button.active {
377    color: var(--foreground);
378    background: var(--card);
379    border-color: var(--border);
380  }
381
382  .header-right {
383    display: flex;
384    align-items: center;
385    gap: 0.5rem;
386  }
387
388  .action-button {
389    display: flex;
390    align-items: center;
391    gap: 0.5rem;
392    padding: 0.4rem 0.75rem;
393    font-size: 0.7rem;
394    font-weight: 500;
395    letter-spacing: 0.08em;
396    color: var(--muted-foreground);
397    background: transparent;
398    border-width: 1px;
399    border-style: solid;
400    border-color: var(--border);
401    cursor: pointer;
402    transition: all 120ms ease;
403  }
404
405  .action-button:hover,
406  .action-button:focus-visible {
407    color: var(--foreground);
408    border-color: color-mix(in srgb, var(--border) 50%, var(--foreground) 50%);
409    background: color-mix(in srgb, var(--card) 30%, transparent);
410    outline: none;
411  }
412
413  .kbd-hint {
414    padding: 0.15rem 0.35rem;
415    font-size: 0.65rem;
416    color: var(--muted-foreground);
417    background: var(--background);
418    border-width: 1px;
419    border-style: solid;
420    border-color: var(--border);
421    margin-left: 0.25rem;
422  }
423
424  .theme-toggle {
425    display: flex;
426    align-items: center;
427    justify-content: center;
428    width: 2.25rem;
429    height: 2.25rem;
430    color: var(--muted-foreground);
431    background: transparent;
432    border-width: 1px;
433    border-style: solid;
434    border-color: var(--border);
435    cursor: pointer;
436    transition: all 120ms ease;
437  }
438
439  .theme-toggle:hover,
440  .theme-toggle:focus-visible {
441    color: var(--foreground);
442    border-color: color-mix(in srgb, var(--border) 50%, var(--foreground) 50%);
443    outline: none;
444  }
445
446  .theme-toggle :global(svg) {
447    flex-shrink: 0;
448  }
449
450  /* Main Content */
451  .terminal-body {
452    display: flex;
453    flex-direction: column;
454    min-height: 0;
455    overflow: hidden;
456    background: var(--background);
457  }
458
459  .terminal-body.scrollable {
460    overflow-y: auto;
461    -webkit-overflow-scrolling: touch;
462    touch-action: pan-y;
463  }
464
465  .empty-shell {
466    flex: 1;
467    display: flex;
468    align-items: center;
469    justify-content: center;
470    gap: 0.5rem;
471    color: var(--muted-foreground);
472    font-size: 1.25rem;
473  }
474
475  .shell-prompt {
476    color: var(--primary, #4ade80);
477  }
478
479  .shell-cursor {
480    animation: blink 1s step-end infinite;
481  }
482
483  @keyframes blink {
484    50% { opacity: 0; }
485  }
486
487  /* Modal Overlay */
488  .modal-overlay {
489    position: fixed;
490    inset: 0;
491    z-index: 100;
492    display: flex;
493    align-items: center;
494    justify-content: center;
495    background: rgb(0 0 0 / 0.7);
496    backdrop-filter: blur(4px);
497    animation: fade-in 150ms ease;
498  }
499
500  @keyframes fade-in {
501    from { opacity: 0; }
502  }
503
504  .confirm-modal {
505    width: 100%;
506    max-width: 420px;
507    margin: 1rem;
508    background: var(--card);
509    border-width: 1px;
510    border-style: solid;
511    border-color: var(--border);
512    box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.6);
513    animation: modal-in 200ms ease;
514  }
515
516  @keyframes modal-in {
517    from {
518      opacity: 0;
519      transform: scale(0.95) translateY(-8px);
520    }
521  }
522
523  .modal-header {
524    display: flex;
525    align-items: center;
526    gap: 0.6rem;
527    padding: 0.85rem 1rem;
528    border-bottom-width: 1px;
529    border-bottom-style: solid;
530    border-bottom-color: var(--border);
531    background: color-mix(in srgb, var(--background) 80%, transparent);
532  }
533
534  .modal-icon {
535    font-size: 1rem;
536    color: var(--warning);
537  }
538
539  .modal-title {
540    font-size: 0.75rem;
541    font-weight: 600;
542    letter-spacing: 0.1em;
543    color: var(--foreground);
544  }
545
546  .modal-message {
547    margin: 0;
548    padding: 1.25rem 1rem;
549    font-size: 0.85rem;
550    color: var(--foreground);
551    line-height: 1.6;
552  }
553
554  .modal-actions {
555    display: flex;
556    gap: 0.5rem;
557    padding: 0.85rem 1rem;
558    border-top-width: 1px;
559    border-top-style: solid;
560    border-top-color: var(--border);
561    background: color-mix(in srgb, var(--background) 50%, transparent);
562  }
563
564  .modal-btn {
565    flex: 1;
566    display: flex;
567    align-items: center;
568    justify-content: center;
569    gap: 0.6rem;
570    padding: 0.6rem 1rem;
571    font-size: 0.72rem;
572    font-weight: 500;
573    letter-spacing: 0.08em;
574    border-width: 1px;
575    border-style: solid;
576    border-color: var(--border);
577    cursor: pointer;
578    transition: all 120ms ease;
579  }
580
581  .modal-btn kbd {
582    padding: 0.1rem 0.3rem;
583    font-size: 0.6rem;
584    background: var(--background);
585    border-width: 1px;
586    border-style: solid;
587    border-color: var(--border);
588    opacity: 0.7;
589  }
590
591  .modal-btn-cancel {
592    color: var(--muted-foreground);
593    background: transparent;
594  }
595
596  .modal-btn-cancel:hover {
597    color: var(--foreground);
598    background: color-mix(in srgb, var(--card) 40%, transparent);
599  }
600
601  .modal-btn-confirm {
602    color: var(--destructive-foreground);
603    background: var(--destructive);
604    border-color: color-mix(in srgb, var(--destructive) 80%, black 20%);
605  }
606
607  .modal-btn-confirm:hover {
608    background: color-mix(in srgb, var(--destructive) 85%, white 15%);
609  }
610
611  /* Responsive */
612  @media (max-width: 768px) {
613    .terminal-header {
614      flex-wrap: wrap;
615      gap: 0.75rem;
616    }
617
618    .header-left {
619      flex-wrap: wrap;
620      gap: 0.75rem;
621    }
622
623    .header-tabs {
624      order: 10;
625      width: 100%;
626    }
627
628    .tab-button {
629      flex: 1;
630      justify-content: center;
631    }
632
633    .action-button .button-text {
634      display: none;
635    }
636  }
637</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