Files
hikari-desktop/src/lib/components/GitPanel.svelte
T
hikari 1ae440659c
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 58s
CI / Lint & Test (push) Successful in 16m33s
CI / Build Linux (push) Successful in 20m56s
CI / Build Windows (cross-compile) (push) Successful in 31m1s
feat: fix git window and add pretty diff viewer (#178)
## Summary

- **Fix git window "Not a git repository" error** — The working directory received from Claude Code is a WSL Linux path (e.g. `/home/naomi/...`), but git commands were being run as native Windows processes with `.current_dir()`. Windows can't resolve WSL paths, causing `git rev-parse --git-dir` to fail. Fixed by routing git commands through `wsl -- git -C <path>` when the working directory starts with `/`.

- **Add syntax highlighting and line numbers to diff view** — Replaced the raw `<pre>` block with a proper `DiffViewer` component featuring:
  - Old/new line number columns with correct tracking across hunks
  - Colour-coded gutter (`+`/`-`) with green/red row backgrounds
  - Syntax highlighting via `highlight.js` using the detected file language, respecting all app themes via `--hljs-*` CSS variables
  - Styled hunk headers and file headers

## New files

- `src/lib/utils/diffParser.ts` — pure diff parsing logic
- `src/lib/utils/diffParser.test.ts` — 30 tests covering all line types, line number tracking, and language detection
- `src/lib/components/DiffViewer.svelte` — the pretty diff viewer component

 This pull request was created with help from Hikari~ 🌸

Reviewed-on: #178
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-06 09:19:16 -08:00

1106 lines
27 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { onMount, onDestroy } from "svelte";
import { claudeStore } from "$lib/stores/claude";
import DiffViewer from "$lib/components/DiffViewer.svelte";
interface GitFileChange {
path: string;
status: string;
}
interface GitStatus {
is_repo: boolean;
branch: string | null;
upstream: string | null;
ahead: number;
behind: number;
staged: GitFileChange[];
unstaged: GitFileChange[];
untracked: string[];
}
interface GitBranch {
name: string;
is_current: boolean;
is_remote: boolean;
}
interface GitLogEntry {
hash: string;
short_hash: string;
author: string;
date: string;
message: string;
}
export let isOpen = false;
export let onClose: () => void;
let status: GitStatus | null = null;
let branches: GitBranch[] = [];
let log: GitLogEntry[] = [];
let loading = false;
let error: string | null = null;
let activeTab: "changes" | "branches" | "history" = "changes";
let commitMessage = "";
let showDiff = false;
let diffContent = "";
let diffFile: string | null = null;
let showBranchInput = false;
let newBranchName = "";
let actionInProgress = false;
let workingDir = "";
let refreshInterval: ReturnType<typeof setInterval> | null = null;
claudeStore.currentWorkingDirectory.subscribe((dir) => {
workingDir = dir;
});
onMount(() => {
if (isOpen) {
refresh();
refreshInterval = setInterval(refresh, 5000);
}
});
onDestroy(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
$: if (isOpen && !refreshInterval) {
refresh();
refreshInterval = setInterval(refresh, 5000);
} else if (!isOpen && refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
async function refresh() {
if (!workingDir) return;
loading = true;
error = null;
try {
status = await invoke<GitStatus>("git_status", { workingDir: workingDir });
if (status.is_repo) {
branches = await invoke<GitBranch[]>("git_branches", { workingDir: workingDir });
log = await invoke<GitLogEntry[]>("git_log", {
workingDir: workingDir,
limit: 20,
});
}
} catch (e) {
error = e as string;
} finally {
loading = false;
}
}
async function viewDiff(filePath: string, staged: boolean) {
try {
diffContent = await invoke<string>("git_diff", {
workingDir: workingDir,
filePath,
staged,
});
diffFile = filePath;
showDiff = true;
} catch (e) {
error = e as string;
}
}
async function stageFile(filePath: string) {
actionInProgress = true;
try {
await invoke("git_stage", { workingDir: workingDir, filePath });
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
async function unstageFile(filePath: string) {
actionInProgress = true;
try {
await invoke("git_unstage", { workingDir: workingDir, filePath });
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
async function stageAll() {
actionInProgress = true;
try {
await invoke("git_stage_all", { workingDir: workingDir });
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
async function discardFile(filePath: string) {
if (!confirm(`Discard changes to ${filePath}? This cannot be undone.`)) return;
actionInProgress = true;
try {
await invoke("git_discard", { workingDir: workingDir, filePath });
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
async function commit() {
if (!commitMessage.trim()) {
error = "Please enter a commit message";
return;
}
actionInProgress = true;
try {
await invoke("git_commit", { workingDir: workingDir, message: commitMessage });
commitMessage = "";
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
async function push() {
actionInProgress = true;
try {
await invoke("git_push", { workingDir: workingDir });
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
async function pull() {
actionInProgress = true;
try {
await invoke("git_pull", { workingDir: workingDir });
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
async function fetch() {
actionInProgress = true;
try {
await invoke("git_fetch", { workingDir: workingDir });
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
async function checkout(branch: string) {
actionInProgress = true;
try {
await invoke("git_checkout", { workingDir: workingDir, branch });
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
async function createBranch() {
if (!newBranchName.trim()) return;
actionInProgress = true;
try {
await invoke("git_create_branch", {
workingDir: workingDir,
branchName: newBranchName,
});
newBranchName = "";
showBranchInput = false;
await refresh();
} catch (e) {
error = e as string;
} finally {
actionInProgress = false;
}
}
function getStatusIcon(status: string): string {
switch (status) {
case "modified":
return "✏️";
case "added":
return "";
case "deleted":
return "🗑️";
case "renamed":
return "📝";
default:
return "❓";
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
if (showDiff) {
showDiff = false;
} else {
onClose();
}
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
{#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="git-panel-overlay" on:click={onClose} role="presentation">
<div
class="git-panel"
on:click|stopPropagation
role="dialog"
aria-label="Git Panel"
tabindex="-1"
>
<div class="git-panel-header">
<h2>🔀 Git</h2>
<button class="close-btn" on:click={onClose} title="Close (Esc)"></button>
</div>
{#if !workingDir}
<div class="git-panel-content">
<p class="empty-state">No working directory selected</p>
</div>
{:else if loading && !status}
<div class="git-panel-content">
<p class="loading">Loading git status...</p>
</div>
{:else if !status?.is_repo}
<div class="git-panel-content">
<p class="empty-state">Not a git repository</p>
</div>
{:else}
<div class="git-info-bar">
<div class="branch-info">
<span class="branch-icon">🌿</span>
<span class="branch-name">{status.branch || "detached"}</span>
{#if status.upstream}
<span class="upstream">{status.upstream}</span>
{/if}
</div>
<div class="sync-status">
{#if status.ahead > 0}
<span class="ahead" title="Commits ahead">{status.ahead}</span>
{/if}
{#if status.behind > 0}
<span class="behind" title="Commits behind">{status.behind}</span>
{/if}
</div>
<div class="quick-actions">
<button
on:click={fetch}
disabled={actionInProgress}
title="Fetch all remotes"
class="icon-btn"
>
🔄
</button>
<button on:click={pull} disabled={actionInProgress} title="Pull" class="icon-btn">
⬇️
</button>
<button
on:click={push}
disabled={actionInProgress || status.ahead === 0}
title="Push"
class="icon-btn"
>
⬆️
</button>
</div>
</div>
{#if error}
<div class="error-banner">
<span>{error}</span>
<button on:click={() => (error = null)}>✕</button>
</div>
{/if}
<div class="tab-bar">
<button
class="tab"
class:active={activeTab === "changes"}
on:click={() => (activeTab = "changes")}
>
Changes
{#if status.staged.length + status.unstaged.length + status.untracked.length > 0}
<span class="badge"
>{status.staged.length + status.unstaged.length + status.untracked.length}</span
>
{/if}
</button>
<button
class="tab"
class:active={activeTab === "branches"}
on:click={() => (activeTab = "branches")}
>
Branches
</button>
<button
class="tab"
class:active={activeTab === "history"}
on:click={() => (activeTab = "history")}
>
History
</button>
</div>
<div class="git-panel-content">
{#if activeTab === "changes"}
{#if status.staged.length > 0}
<div class="file-section">
<h3>Staged Changes ({status.staged.length})</h3>
<div class="file-list">
{#each status.staged as file (file.path)}
<div class="file-item staged">
<span class="status-icon">{getStatusIcon(file.status)}</span>
<span class="file-path" title={file.path}>{file.path}</span>
<div class="file-actions">
<button
on:click={() => viewDiff(file.path, true)}
title="View diff"
class="icon-btn small"
>
👁️
</button>
<button
on:click={() => unstageFile(file.path)}
disabled={actionInProgress}
title="Unstage"
class="icon-btn small"
>
</button>
</div>
</div>
{/each}
</div>
</div>
{/if}
{#if status.unstaged.length > 0}
<div class="file-section">
<h3>
Unstaged Changes ({status.unstaged.length})
<button
on:click={stageAll}
disabled={actionInProgress}
class="btn-trans-gradient stage-all-btn"
title="Stage all"
>
Stage All
</button>
</h3>
<div class="file-list">
{#each status.unstaged as file (file.path)}
<div class="file-item unstaged">
<span class="status-icon">{getStatusIcon(file.status)}</span>
<span class="file-path" title={file.path}>{file.path}</span>
<div class="file-actions">
<button
on:click={() => viewDiff(file.path, false)}
title="View diff"
class="icon-btn small"
>
👁️
</button>
<button
on:click={() => stageFile(file.path)}
disabled={actionInProgress}
title="Stage"
class="icon-btn small"
>
</button>
<button
on:click={() => discardFile(file.path)}
disabled={actionInProgress}
title="Discard changes"
class="icon-btn small danger"
>
🗑️
</button>
</div>
</div>
{/each}
</div>
</div>
{/if}
{#if status.untracked.length > 0}
<div class="file-section">
<h3>Untracked Files ({status.untracked.length})</h3>
<div class="file-list">
{#each status.untracked as file (file)}
<div class="file-item untracked">
<span class="status-icon"></span>
<span class="file-path" title={file}>{file}</span>
<div class="file-actions">
<button
on:click={() => stageFile(file)}
disabled={actionInProgress}
title="Stage"
class="icon-btn small"
>
</button>
</div>
</div>
{/each}
</div>
</div>
{/if}
{#if status.staged.length === 0 && status.unstaged.length === 0 && status.untracked.length === 0}
<p class="empty-state">✨ Working tree clean</p>
{/if}
{#if status.staged.length > 0}
<div class="commit-section">
<textarea
bind:value={commitMessage}
placeholder="Commit message..."
rows="3"
disabled={actionInProgress}
></textarea>
<button
on:click={commit}
disabled={actionInProgress || !commitMessage.trim()}
class="btn-trans-gradient commit-btn"
>
Commit
</button>
</div>
{/if}
{:else if activeTab === "branches"}
<div class="branch-section">
<div class="branch-actions">
{#if showBranchInput}
<div class="new-branch-input">
<input
type="text"
bind:value={newBranchName}
placeholder="New branch name..."
on:keydown={(e) => e.key === "Enter" && createBranch()}
/>
<button
on:click={createBranch}
disabled={actionInProgress}
class="btn-trans-gradient">Create</button
>
<button on:click={() => (showBranchInput = false)}>Cancel</button>
</div>
{:else}
<button
on:click={() => (showBranchInput = true)}
class="btn-trans-gradient new-branch-btn"
>
New Branch
</button>
{/if}
</div>
<h3>Local Branches</h3>
<div class="branch-list">
{#each branches.filter((b) => !b.is_remote) as branch (branch.name)}
<div class="branch-item" class:current={branch.is_current}>
<span class="branch-icon">{branch.is_current ? "✓" : "○"}</span>
<span class="branch-name">{branch.name}</span>
{#if !branch.is_current}
<button
on:click={() => checkout(branch.name)}
disabled={actionInProgress}
class="checkout-btn"
>
Checkout
</button>
{/if}
</div>
{/each}
</div>
{#if branches.filter((b) => b.is_remote).length > 0}
<h3>Remote Branches</h3>
<div class="branch-list">
{#each branches.filter((b) => b.is_remote) as branch (branch.name)}
<div class="branch-item remote">
<span class="branch-icon">☁️</span>
<span class="branch-name">{branch.name}</span>
</div>
{/each}
</div>
{/if}
</div>
{:else if activeTab === "history"}
<div class="history-list">
{#each log as entry (entry.hash)}
<div class="log-entry">
<div class="log-header">
<span class="commit-hash" title={entry.hash}>{entry.short_hash}</span>
<span class="commit-date">{entry.date}</span>
</div>
<div class="commit-message">{entry.message}</div>
<div class="commit-author">by {entry.author}</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
</div>
{#if showDiff}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="diff-overlay" on:click={() => (showDiff = false)} role="presentation">
<div
class="diff-modal"
on:click|stopPropagation
role="dialog"
aria-label="Diff View"
tabindex="-1"
>
<div class="diff-header">
<h3>📄 {diffFile}</h3>
<button on:click={() => (showDiff = false)} title="Close"></button>
</div>
<div class="diff-content">
<DiffViewer {diffContent} filePath={diffFile ?? ""} />
</div>
</div>
</div>
{/if}
{/if}
<style>
.git-panel-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.git-panel {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
width: 600px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.git-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--border-color);
}
.git-panel-header h2 {
margin: 0;
font-size: 1.2rem;
}
.close-btn {
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
font-size: 1.2rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.close-btn:hover {
background: var(--bg-tertiary);
}
.git-info-bar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.branch-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
overflow: hidden;
}
.branch-icon {
font-size: 1rem;
}
.branch-name {
font-weight: 600;
color: var(--accent-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upstream {
color: var(--text-secondary);
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sync-status {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.ahead,
.behind {
font-size: 0.85rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
}
.ahead {
background: var(--success-color);
color: white;
}
.behind {
background: var(--warning-color);
color: white;
}
.quick-actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.icon-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 1rem;
}
.icon-btn:hover:not(:disabled) {
background: var(--bg-secondary);
}
.icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.icon-btn.small {
font-size: 0.85rem;
padding: 0.15rem 0.35rem;
}
.icon-btn.danger:hover:not(:disabled) {
background: rgba(255, 0, 0, 0.2);
}
.error-banner {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
background: rgba(255, 0, 0, 0.1);
border-bottom: 1px solid var(--error-color);
color: var(--error-color);
}
.error-banner button {
background: none;
border: none;
color: var(--error-color);
cursor: pointer;
}
.tab-bar {
display: flex;
border-bottom: 1px solid var(--border-color);
}
.tab {
flex: 1;
padding: 0.75rem;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab:hover {
background: var(--bg-tertiary);
}
.tab.active {
color: var(--accent-primary);
border-bottom-color: var(--accent-primary);
}
.badge {
background: var(--accent-primary);
color: white;
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
border-radius: 10px;
margin-left: 0.25rem;
}
.git-panel-content {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.empty-state,
.loading {
text-align: center;
color: var(--text-secondary);
padding: 2rem;
}
.file-section {
margin-bottom: 1.5rem;
}
.file-section h3 {
font-size: 0.9rem;
color: var(--text-secondary);
margin: 0 0 0.5rem 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.stage-all-btn {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.file-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.file-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
background: var(--bg-tertiary);
border-radius: 4px;
font-size: 0.85rem;
}
.file-item.staged {
border-left: 3px solid var(--success-color);
}
.file-item.unstaged {
border-left: 3px solid var(--warning-color);
}
.file-item.untracked {
border-left: 3px solid var(--text-secondary);
}
.status-icon {
font-size: 0.8rem;
}
.file-path {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
}
.file-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s;
}
.file-item:hover .file-actions {
opacity: 1;
}
.commit-section {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.commit-section textarea {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.5rem;
color: var(--text-primary);
font-family: inherit;
resize: vertical;
}
.commit-btn {
border: none;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
}
.branch-section h3 {
font-size: 0.9rem;
color: var(--text-secondary);
margin: 1rem 0 0.5rem 0;
}
.branch-actions {
margin-bottom: 1rem;
}
.new-branch-btn {
border: none;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
}
.new-branch-input {
display: flex;
gap: 0.5rem;
}
.new-branch-input input {
flex: 1;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.5rem;
color: var(--text-primary);
}
.new-branch-input button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.new-branch-input button:last-of-type {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.branch-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.branch-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: var(--bg-tertiary);
border-radius: 4px;
}
.branch-item.current {
background: color-mix(in srgb, var(--accent-primary) 10%, transparent);
border-left: 3px solid var(--accent-primary);
}
.branch-item.remote {
opacity: 0.7;
}
.branch-item .branch-name {
flex: 1;
font-family: var(--font-mono);
font-size: 0.9rem;
color: var(--text-primary);
font-weight: normal;
}
.checkout-btn {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
}
.branch-item:hover .checkout-btn {
opacity: 1;
}
.history-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.log-entry {
padding: 0.75rem;
background: var(--bg-tertiary);
border-radius: 4px;
border-left: 3px solid var(--accent-primary);
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.commit-hash {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--accent-primary);
cursor: pointer;
}
.commit-date {
font-size: 0.75rem;
color: var(--text-secondary);
}
.commit-message {
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.commit-author {
font-size: 0.75rem;
color: var(--text-secondary);
}
.diff-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
}
.diff-modal {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
width: 800px;
max-width: 95vw;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.diff-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.diff-header h3 {
margin: 0;
font-size: 1rem;
font-family: var(--font-mono);
}
.diff-header button {
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
font-size: 1.2rem;
}
.diff-content {
flex: 1;
overflow: auto;
margin: 0;
background: var(--bg-primary);
}
</style>