v0.27.2 5960dd8

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