init
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
/node_modules
|
||||
/dist
|
||||
/src-tauri/target
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,45 @@
|
||||
# Tauri Git Lite
|
||||
|
||||
Eine kleine Git-Desktop-App mit Tauri, Rust und Svelte.
|
||||
|
||||
## Funktionen
|
||||
|
||||
- Repository per lokalem Pfad öffnen
|
||||
- aktuellen Branch, Upstream, Ahead/Behind und Arbeitsbaumstatus anzeigen
|
||||
- Branches auflisten und auschecken
|
||||
- Branches in den aktuellen Branch mergen
|
||||
- Commit-History mit geänderten Dateien anzeigen
|
||||
- Explorer-Ansicht mit Ordnerbaum sowie getrackten und ungetrackten Dateien
|
||||
- Datei- und Ordner-History direkt aus dem Explorer anzeigen
|
||||
- einzelne Dateien stagen, unstagen und wiederherstellen
|
||||
- einzelne Dateien oder ganze Ordner aus einem History-Commit wiederherstellen
|
||||
- aktuellen Branch auf einen ausgewählten Commit zurücksetzen
|
||||
- Commit mit Message erstellen
|
||||
- Pull mit Fast-Forward-Strategie
|
||||
- Push auf den konfigurierten Upstream
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm run tauri:dev
|
||||
```
|
||||
|
||||
Frontend allein:
|
||||
|
||||
```powershell
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Checks:
|
||||
|
||||
```powershell
|
||||
npm run check
|
||||
npm run build
|
||||
cd src-tauri
|
||||
cargo check
|
||||
```
|
||||
|
||||
## Hinweis
|
||||
|
||||
Die App nutzt das lokal installierte `git` CLI. Ein Repository muss daher bereits auf der Maschine vorhanden sein, und Push/Pull verwenden die Credentials, die Git lokal kennt.
|
||||
@@ -0,0 +1,74 @@
|
||||
# API Contract
|
||||
|
||||
The frontend calls Tauri commands with `@tauri-apps/api/core.invoke`.
|
||||
|
||||
## Shared payloads
|
||||
|
||||
```ts
|
||||
type FileStatusKind = "modified" | "added" | "deleted" | "renamed" | "untracked" | "conflicted" | "unknown";
|
||||
|
||||
interface GitStatus {
|
||||
repo_path: string;
|
||||
current_branch: string | null;
|
||||
upstream: string | null;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
files: GitFileStatus[];
|
||||
clean: boolean;
|
||||
}
|
||||
|
||||
interface GitFileStatus {
|
||||
path: string;
|
||||
old_path: string | null;
|
||||
staged: FileStatusKind | null;
|
||||
unstaged: FileStatusKind | null;
|
||||
}
|
||||
|
||||
interface GitBranch {
|
||||
name: string;
|
||||
current: boolean;
|
||||
remote: boolean;
|
||||
}
|
||||
|
||||
interface GitCommit {
|
||||
hash: string;
|
||||
short_hash: string;
|
||||
summary: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
date: string;
|
||||
refs: string[];
|
||||
files: GitCommitFile[];
|
||||
}
|
||||
|
||||
interface GitCommitFile {
|
||||
path: string;
|
||||
old_path: string | null;
|
||||
status: FileStatusKind;
|
||||
}
|
||||
|
||||
interface GitRepositoryFile {
|
||||
path: string;
|
||||
tracked: boolean;
|
||||
status: FileStatusKind | null;
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
- `open_repository(path: string): Promise<GitStatus>`
|
||||
- `get_status(path: string): Promise<GitStatus>`
|
||||
- `list_branches(path: string): Promise<GitBranch[]>`
|
||||
- `checkout_branch(path: string, branch: string): Promise<GitStatus>`
|
||||
- `stage_files(path: string, files: string[]): Promise<GitStatus>`
|
||||
- `unstage_files(path: string, files: string[]): Promise<GitStatus>`
|
||||
- `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>`
|
||||
- `commit(path: string, message: string): Promise<GitStatus>`
|
||||
- `pull(path: string): Promise<GitStatus>`
|
||||
- `push(path: string): Promise<GitStatus>`
|
||||
- `list_commits(path: string, limit?: number): Promise<GitCommit[]>`
|
||||
- `restore_to_commit(path: string, commit: string): Promise<GitStatus>`
|
||||
- `restore_file_from_commit(path: string, commit: string, file: string): Promise<GitStatus>` (the `file` argument can also be a folder path)
|
||||
- `merge_branch(path: string, branch: string): Promise<GitStatus>`
|
||||
- `list_repository_files(path: string): Promise<GitRepositoryFile[]>`
|
||||
- `list_file_history(path: string, file: string, limit?: number): Promise<GitCommit[]>` (the `file` argument can also be a folder path)
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri Git Lite</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1794
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "tauri-git-lite",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.21.0",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"svelte": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
/gen/schemas
|
||||
Generated
+4389
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "tauri_git_lite"
|
||||
version = "0.1.0"
|
||||
description = "Rust backend for a lightweight Git desktop client"
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tauri = { version = "2", features = [] }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default permissions for the desktop window",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod git;
|
||||
|
||||
use git::{
|
||||
checkout_branch, commit, get_status, list_branches, list_commits, list_file_history,
|
||||
list_repository_files, merge_branch, open_repository, pull, push, restore_file_from_commit,
|
||||
restore_files, restore_to_commit, stage_files, unstage_files,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
open_repository,
|
||||
get_status,
|
||||
list_branches,
|
||||
checkout_branch,
|
||||
stage_files,
|
||||
unstage_files,
|
||||
restore_files,
|
||||
commit,
|
||||
pull,
|
||||
push,
|
||||
list_commits,
|
||||
restore_to_commit,
|
||||
restore_file_from_commit,
|
||||
merge_branch,
|
||||
list_repository_files,
|
||||
list_file_history
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Tauri Git Lite",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.example.tauri-git-lite",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://127.0.0.1:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Tauri Git Lite",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": ["icons/icon.ico"]
|
||||
}
|
||||
}
|
||||
+973
@@ -0,0 +1,973 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Check,
|
||||
Download,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
GitMerge,
|
||||
History,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Undo2,
|
||||
Upload,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
import {
|
||||
checkoutBranch,
|
||||
commit,
|
||||
getStatus,
|
||||
listBranches,
|
||||
listCommits,
|
||||
listFileHistory,
|
||||
listRepositoryFiles,
|
||||
mergeBranch,
|
||||
openRepository,
|
||||
pull,
|
||||
push,
|
||||
restoreFileFromCommit,
|
||||
restoreFiles,
|
||||
restoreToCommit,
|
||||
stageFiles,
|
||||
unstageFiles,
|
||||
} from "./lib/git";
|
||||
import type {
|
||||
FileStatusKind,
|
||||
GitBranch as GitBranchInfo,
|
||||
GitCommit,
|
||||
GitCommitFile,
|
||||
GitFileStatus,
|
||||
GitRepositoryFile,
|
||||
GitStatus,
|
||||
} from "./lib/types";
|
||||
|
||||
type ExplorerNodeKind = "folder" | "file";
|
||||
|
||||
interface ExplorerNode {
|
||||
name: string;
|
||||
path: string;
|
||||
kind: ExplorerNodeKind;
|
||||
status: FileStatusKind | null;
|
||||
tracked: boolean;
|
||||
depth: number;
|
||||
children: ExplorerNode[];
|
||||
}
|
||||
|
||||
let repoPath = "";
|
||||
let activeRepoPath = "";
|
||||
let status: GitStatus | null = null;
|
||||
let branches: GitBranchInfo[] = [];
|
||||
let commits: GitCommit[] = [];
|
||||
let repoFiles: GitRepositoryFile[] = [];
|
||||
let selectedExplorerPath = "";
|
||||
let selectedExplorerKind: ExplorerNodeKind = "file";
|
||||
let expandedExplorerPaths = new Set<string>();
|
||||
let fileHistory: GitCommit[] = [];
|
||||
let commitMessage = "";
|
||||
let errorMessage = "";
|
||||
let operation = "";
|
||||
|
||||
$: isBusy = operation.length > 0;
|
||||
$: hasRepository = activeRepoPath.length > 0 && status !== null;
|
||||
$: changedFiles = status?.files ?? [];
|
||||
$: stagedCount = status?.files.filter((file) => file.staged !== null).length ?? 0;
|
||||
$: unstagedCount = status?.files.filter((file) => file.unstaged !== null).length ?? 0;
|
||||
$: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !isBusy;
|
||||
$: explorerTree = buildExplorerTree(repoFiles);
|
||||
$: visibleExplorerNodes = flattenExplorerTree(explorerTree, expandedExplorerPaths);
|
||||
$: selectedExplorerLabel = selectedExplorerPath
|
||||
? `${selectedExplorerKind === "folder" ? "Folder" : "File"} history`
|
||||
: "File history";
|
||||
|
||||
function applyStatus(nextStatus: GitStatus) {
|
||||
status = nextStatus;
|
||||
activeRepoPath = nextStatus.repo_path || activeRepoPath || repoPath.trim();
|
||||
repoPath = activeRepoPath;
|
||||
}
|
||||
|
||||
function buildExplorerTree(files: GitRepositoryFile[]): ExplorerNode[] {
|
||||
const roots: ExplorerNode[] = [];
|
||||
const folders = new Map<string, ExplorerNode>();
|
||||
|
||||
for (const file of [...files].sort((left, right) => left.path.localeCompare(right.path))) {
|
||||
const parts = file.path.split(/[\\/]+/).filter(Boolean);
|
||||
let currentPath = "";
|
||||
let siblings = roots;
|
||||
let ancestors: ExplorerNode[] = [];
|
||||
|
||||
for (let index = 0; index < parts.length - 1; index += 1) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[index]}` : parts[index];
|
||||
let folderNode = folders.get(currentPath);
|
||||
|
||||
if (!folderNode) {
|
||||
folderNode = {
|
||||
name: parts[index],
|
||||
path: currentPath,
|
||||
kind: "folder",
|
||||
status: null,
|
||||
tracked: true,
|
||||
depth: index,
|
||||
children: [],
|
||||
};
|
||||
folders.set(currentPath, folderNode);
|
||||
siblings.push(folderNode);
|
||||
}
|
||||
|
||||
ancestors = [...ancestors, folderNode];
|
||||
siblings = folderNode.children;
|
||||
}
|
||||
|
||||
const fileNode: ExplorerNode = {
|
||||
name: parts[parts.length - 1] ?? file.path,
|
||||
path: file.path,
|
||||
kind: "file",
|
||||
status: file.status,
|
||||
tracked: file.tracked,
|
||||
depth: Math.max(parts.length - 1, 0),
|
||||
children: [],
|
||||
};
|
||||
siblings.push(fileNode);
|
||||
|
||||
for (const folderNode of ancestors) {
|
||||
folderNode.status = mergeExplorerStatus(folderNode.status, file.status);
|
||||
folderNode.tracked = folderNode.tracked && file.tracked;
|
||||
}
|
||||
}
|
||||
|
||||
sortExplorerNodes(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
function mergeExplorerStatus(
|
||||
current: FileStatusKind | null,
|
||||
next: FileStatusKind | null,
|
||||
): FileStatusKind | null {
|
||||
if (!next) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
return next;
|
||||
}
|
||||
|
||||
const priority: FileStatusKind[] = [
|
||||
"conflicted",
|
||||
"modified",
|
||||
"renamed",
|
||||
"deleted",
|
||||
"added",
|
||||
"untracked",
|
||||
"unknown",
|
||||
];
|
||||
|
||||
return priority.indexOf(next) < priority.indexOf(current) ? next : current;
|
||||
}
|
||||
|
||||
function sortExplorerNodes(nodes: ExplorerNode[]) {
|
||||
nodes.sort((left, right) => {
|
||||
if (left.kind !== right.kind) {
|
||||
return left.kind === "folder" ? -1 : 1;
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
|
||||
for (const node of nodes) {
|
||||
sortExplorerNodes(node.children);
|
||||
}
|
||||
}
|
||||
|
||||
function flattenExplorerTree(nodes: ExplorerNode[], expanded: Set<string>): ExplorerNode[] {
|
||||
const visible: ExplorerNode[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
visible.push(node);
|
||||
|
||||
if (node.kind === "folder" && expanded.has(node.path)) {
|
||||
visible.push(...flattenExplorerTree(node.children, expanded));
|
||||
}
|
||||
}
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
function explorerPathExists(files: GitRepositoryFile[], path: string): boolean {
|
||||
if (!path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedPath = normalizeExplorerPath(path);
|
||||
return files.some((file) => {
|
||||
const filePath = normalizeExplorerPath(file.path);
|
||||
return filePath === normalizedPath || filePath.startsWith(`${normalizedPath}/`);
|
||||
});
|
||||
}
|
||||
|
||||
function defaultExpandedExplorerPaths(files: GitRepositoryFile[]): Set<string> {
|
||||
const expanded = new Set<string>();
|
||||
|
||||
for (const file of files) {
|
||||
const parts = file.path.split(/[\\/]+/).filter(Boolean);
|
||||
if (parts.length > 1) {
|
||||
expanded.add(parts[0]);
|
||||
}
|
||||
}
|
||||
|
||||
return expanded;
|
||||
}
|
||||
|
||||
function normalizeExplorerPath(path: string): string {
|
||||
return path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
function errorToMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(error) ?? "Unknown error";
|
||||
} catch {
|
||||
return "Unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
async function runOperation(label: string, task: () => Promise<void>) {
|
||||
if (isBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
operation = label;
|
||||
errorMessage = "";
|
||||
|
||||
try {
|
||||
await task();
|
||||
} catch (error) {
|
||||
errorMessage = errorToMessage(error);
|
||||
} finally {
|
||||
operation = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBranchList(path = activeRepoPath) {
|
||||
branches = await listBranches(path);
|
||||
}
|
||||
|
||||
async function refreshCommitHistory(path = activeRepoPath) {
|
||||
commits = await listCommits(path, 100);
|
||||
}
|
||||
|
||||
async function refreshExplorerFiles(path = activeRepoPath) {
|
||||
repoFiles = await listRepositoryFiles(path);
|
||||
|
||||
if (expandedExplorerPaths.size === 0) {
|
||||
expandedExplorerPaths = defaultExpandedExplorerPaths(repoFiles);
|
||||
}
|
||||
|
||||
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
|
||||
selectedExplorerPath = "";
|
||||
selectedExplorerKind = "file";
|
||||
fileHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath) {
|
||||
fileHistory = file ? await listFileHistory(path, file, 100) : [];
|
||||
}
|
||||
|
||||
async function openRepo() {
|
||||
const path = repoPath.trim();
|
||||
|
||||
if (!path) {
|
||||
errorMessage = "Enter a repository path.";
|
||||
return;
|
||||
}
|
||||
|
||||
await runOperation("Opening repository", async () => {
|
||||
const nextStatus = await openRepository(path);
|
||||
applyStatus(nextStatus);
|
||||
branches = [];
|
||||
commits = [];
|
||||
repoFiles = [];
|
||||
selectedExplorerPath = "";
|
||||
selectedExplorerKind = "file";
|
||||
expandedExplorerPaths = new Set<string>();
|
||||
fileHistory = [];
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshRepo() {
|
||||
if (!activeRepoPath) {
|
||||
await openRepo();
|
||||
return;
|
||||
}
|
||||
|
||||
await runOperation("Refreshing", async () => {
|
||||
applyStatus(await getStatus(activeRepoPath));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function checkout(branch: GitBranchInfo) {
|
||||
if (!activeRepoPath || branch.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runOperation(`Checking out ${branch.name}`, async () => {
|
||||
applyStatus(await checkoutBranch(activeRepoPath, branch.name));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function merge(branch: GitBranchInfo) {
|
||||
if (!activeRepoPath || branch.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runOperation(`Merging ${branch.name}`, async () => {
|
||||
applyStatus(await mergeBranch(activeRepoPath, branch.name));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function stageFile(file: GitFileStatus) {
|
||||
await runOperation(`Staging ${file.path}`, async () => {
|
||||
applyStatus(await stageFiles(activeRepoPath, [file.path]));
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function unstageFile(file: GitFileStatus) {
|
||||
await runOperation(`Unstaging ${file.path}`, async () => {
|
||||
applyStatus(await unstageFiles(activeRepoPath, [file.path]));
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreFile(file: GitFileStatus, staged: boolean) {
|
||||
await runOperation(`Restoring ${file.path}`, async () => {
|
||||
applyStatus(await restoreFiles(activeRepoPath, [file.path], staged));
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function commitChanges() {
|
||||
const message = commitMessage.trim();
|
||||
|
||||
if (!message || !activeRepoPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runOperation("Committing", async () => {
|
||||
applyStatus(await commit(activeRepoPath, message));
|
||||
commitMessage = "";
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function pullRepo() {
|
||||
if (!activeRepoPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runOperation("Pulling", async () => {
|
||||
applyStatus(await pull(activeRepoPath));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function pushRepo() {
|
||||
if (!activeRepoPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runOperation("Pushing", async () => {
|
||||
applyStatus(await push(activeRepoPath));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreCommit(target: GitCommit) {
|
||||
if (!activeRepoPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Reset current branch to ${target.short_hash}?\n\nThis moves the current branch and discards tracked local changes.`,
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runOperation(`Restoring ${target.short_hash}`, async () => {
|
||||
applyStatus(await restoreToCommit(activeRepoPath, target.hash));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreCommitFile(commit: GitCommit, file: GitCommitFile) {
|
||||
if (!activeRepoPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Restore ${file.path} from ${commit.short_hash}?\n\nThis changes the file in your working tree so you can review and commit it.`,
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runOperation(`Restoring ${file.path}`, async () => {
|
||||
applyStatus(await restoreFileFromCommit(activeRepoPath, commit.hash, file.path));
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleExplorerFolder(node: ExplorerNode) {
|
||||
if (node.kind === "folder") {
|
||||
const nextExpanded = new Set(expandedExplorerPaths);
|
||||
if (nextExpanded.has(node.path)) {
|
||||
nextExpanded.delete(node.path);
|
||||
} else {
|
||||
nextExpanded.add(node.path);
|
||||
}
|
||||
expandedExplorerPaths = nextExpanded;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectExplorerNode(node: ExplorerNode) {
|
||||
if (!activeRepoPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedExplorerPath === node.path && selectedExplorerKind === node.kind) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedExplorerPath = node.path;
|
||||
selectedExplorerKind = node.kind;
|
||||
await runOperation(`Loading ${node.path} history`, async () => {
|
||||
await refreshFileHistory(activeRepoPath, node.path);
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreSelectedFileFromCommit(commit: GitCommit) {
|
||||
if (!activeRepoPath || !selectedExplorerPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetKind = selectedExplorerKind === "folder" ? "folder" : "file";
|
||||
const confirmed = window.confirm(
|
||||
`Restore ${targetKind} ${selectedExplorerPath} from ${commit.short_hash}?\n\nThis changes the selected ${targetKind} in your working tree so you can review and commit it.`,
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runOperation(`Restoring ${selectedExplorerPath}`, async () => {
|
||||
applyStatus(await restoreFileFromCommit(activeRepoPath, commit.hash, selectedExplorerPath));
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
function submitRepo(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
void openRepo();
|
||||
}
|
||||
|
||||
function submitCommit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
void commitChanges();
|
||||
}
|
||||
|
||||
function displayPath(file: GitFileStatus): string {
|
||||
if (!file.old_path) {
|
||||
return file.path;
|
||||
}
|
||||
|
||||
return `${file.old_path} -> ${file.path}`;
|
||||
}
|
||||
|
||||
function displayCommitFile(file: GitCommitFile): string {
|
||||
if (!file.old_path) {
|
||||
return file.path;
|
||||
}
|
||||
|
||||
return `${file.old_path} -> ${file.path}`;
|
||||
}
|
||||
|
||||
function statusLabel(kind: FileStatusKind | null): string {
|
||||
return kind ? kind : "none";
|
||||
}
|
||||
|
||||
function formatCommitDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Tauri Git Lite</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<form class="repo-form" onsubmit={submitRepo}>
|
||||
<label for="repo-path">Repository</label>
|
||||
<input
|
||||
id="repo-path"
|
||||
bind:value={repoPath}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="C:\path\to\repository"
|
||||
disabled={isBusy}
|
||||
/>
|
||||
<button class="primary-button" type="submit" disabled={isBusy || repoPath.trim().length === 0}>
|
||||
{#if operation === "Opening repository"}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Open
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="toolbar" aria-label="Repository actions">
|
||||
<button type="button" title="Pull" onclick={pullRepo} disabled={!hasRepository || isBusy}>
|
||||
<Download size={16} aria-hidden="true" />
|
||||
Pull
|
||||
</button>
|
||||
<button type="button" title="Push" onclick={pushRepo} disabled={!hasRepository || isBusy}>
|
||||
<Upload size={16} aria-hidden="true" />
|
||||
Push
|
||||
</button>
|
||||
<button type="button" title="Refresh" onclick={refreshRepo} disabled={isBusy || repoPath.trim().length === 0}>
|
||||
<RefreshCw class={operation === "Refreshing" ? "spin" : ""} size={16} aria-hidden="true" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if errorMessage}
|
||||
<section class="notice error" role="alert">
|
||||
<AlertCircle size={17} aria-hidden="true" />
|
||||
<span>{errorMessage}</span>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if operation && operation !== "Opening repository"}
|
||||
<section class="notice busy" aria-live="polite">
|
||||
<LoaderCircle class="spin" size={17} aria-hidden="true" />
|
||||
<span>{operation}</span>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="workspace" aria-label="Git workspace">
|
||||
<aside class="left-sidebar" aria-label="Repository navigation">
|
||||
<section class="branches" aria-label="Branches">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="eyebrow">Branches</span>
|
||||
<h2>Refs</h2>
|
||||
</div>
|
||||
<span class="counter">{branches.length}</span>
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
<p class="empty-note">Open a repository to list branches.</p>
|
||||
{:else if branches.length === 0}
|
||||
<p class="empty-note">No branches returned.</p>
|
||||
{:else}
|
||||
<div class="branch-list">
|
||||
{#each branches as branch (branch.name)}
|
||||
<article class:current={branch.current} class="branch-row">
|
||||
<div class="branch-info">
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{branch.name}</strong>
|
||||
<span>{branch.remote ? "remote" : "local"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if branch.current}
|
||||
<span class="active-pill">Current</span>
|
||||
{:else}
|
||||
<div class="branch-actions">
|
||||
<button type="button" onclick={() => checkout(branch)} disabled={isBusy}>Checkout</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => merge(branch)}
|
||||
disabled={isBusy}
|
||||
title="Merge branch into current branch"
|
||||
>
|
||||
<GitMerge size={15} aria-hidden="true" />
|
||||
Merge
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="explorer-panel" aria-label="File explorer">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="eyebrow">Explorer</span>
|
||||
<h2>Files</h2>
|
||||
</div>
|
||||
<span class="counter">{repoFiles.length}</span>
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
<p class="empty-note">Open a repository to browse files.</p>
|
||||
{:else if repoFiles.length === 0}
|
||||
<p class="empty-note">No files returned.</p>
|
||||
{:else}
|
||||
<div class="explorer-list">
|
||||
{#each visibleExplorerNodes as node (`${node.kind}:${node.path}`)}
|
||||
<div
|
||||
class:active={selectedExplorerPath === node.path && selectedExplorerKind === node.kind}
|
||||
class:folder={node.kind === "folder"}
|
||||
class="explorer-row"
|
||||
style={`--depth: ${node.depth}`}
|
||||
title={node.path}
|
||||
>
|
||||
{#if node.kind === "folder"}
|
||||
<button
|
||||
type="button"
|
||||
class="tree-toggle"
|
||||
onclick={() => toggleExplorerFolder(node)}
|
||||
disabled={isBusy}
|
||||
title={expandedExplorerPaths.has(node.path) ? "Collapse folder" : "Expand folder"}
|
||||
>
|
||||
{#if expandedExplorerPaths.has(node.path)}
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
{:else}
|
||||
<ChevronRight size={14} aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if expandedExplorerPaths.has(node.path)}
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
{:else}
|
||||
<Folder size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="tree-spacer"></span>
|
||||
<FileText size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="explorer-select"
|
||||
onclick={() => selectExplorerNode(node)}
|
||||
disabled={isBusy}
|
||||
title={`Show history for ${node.path}`}
|
||||
>
|
||||
<span>{node.name}</span>
|
||||
</button>
|
||||
{#if node.status}
|
||||
<small class={`status-badge ${node.status}`}>{statusLabel(node.status)}</small>
|
||||
{:else if !node.tracked}
|
||||
<small class="status-badge untracked">untracked</small>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="main-panel" aria-label="Repository status">
|
||||
<div class="repo-summary">
|
||||
<div>
|
||||
<span class="eyebrow">Current repo</span>
|
||||
<h1>{status?.current_branch ?? "No repository"}</h1>
|
||||
<p>{activeRepoPath || "Enter a path and open a repository."}</p>
|
||||
</div>
|
||||
|
||||
<div class="sync-stats" aria-label="Sync state">
|
||||
<span title="Upstream">{status?.upstream ?? "No upstream"}</span>
|
||||
<strong>{status?.ahead ?? 0} ahead</strong>
|
||||
<strong>{status?.behind ?? 0} behind</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-grid">
|
||||
<section class="status-panel">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="eyebrow">Working tree</span>
|
||||
<h2>Status</h2>
|
||||
</div>
|
||||
<div class="status-counts">
|
||||
<span>{stagedCount} staged</span>
|
||||
<span>{unstagedCount} unstaged</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
<div class="blank-state">No repository loaded.</div>
|
||||
{:else if status?.clean}
|
||||
<div class="blank-state">Working tree is clean.</div>
|
||||
{:else if changedFiles.length === 0}
|
||||
<div class="blank-state">No file changes returned.</div>
|
||||
{:else}
|
||||
<div class="file-list">
|
||||
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||
<article class="file-row">
|
||||
<div class="file-title">
|
||||
<strong title={displayPath(file)}>{displayPath(file)}</strong>
|
||||
</div>
|
||||
|
||||
<div class="change-lanes">
|
||||
<div class:inactive={!file.staged} class="change-lane">
|
||||
<span class="lane-name">Staged</span>
|
||||
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
|
||||
<div class="lane-actions">
|
||||
{#if file.staged}
|
||||
<button type="button" onclick={() => unstageFile(file)} disabled={isBusy} title="Unstage file">
|
||||
<Undo2 size={15} aria-hidden="true" />
|
||||
Unstage
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => restoreFile(file, true)}
|
||||
disabled={isBusy}
|
||||
title="Restore staged changes"
|
||||
>
|
||||
<RotateCcw size={15} aria-hidden="true" />
|
||||
Restore
|
||||
</button>
|
||||
{:else}
|
||||
<span class="quiet">No staged change</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class:inactive={!file.unstaged} class="change-lane">
|
||||
<span class="lane-name">Unstaged</span>
|
||||
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
||||
<div class="lane-actions">
|
||||
{#if file.unstaged}
|
||||
<button type="button" onclick={() => stageFile(file)} disabled={isBusy} title="Stage file">
|
||||
<Check size={15} aria-hidden="true" />
|
||||
Stage
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => restoreFile(file, false)}
|
||||
disabled={isBusy}
|
||||
title="Restore unstaged changes"
|
||||
>
|
||||
<RotateCcw size={15} aria-hidden="true" />
|
||||
Restore
|
||||
</button>
|
||||
{:else}
|
||||
<span class="quiet">No unstaged change</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<aside class="side-stack" aria-label="Commit and history">
|
||||
<section class="commit-panel" aria-label="Commit">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="eyebrow">Commit</span>
|
||||
<h2>Message</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="commit-form" onsubmit={submitCommit}>
|
||||
<textarea
|
||||
bind:value={commitMessage}
|
||||
placeholder="Commit message"
|
||||
rows="7"
|
||||
disabled={!hasRepository || isBusy}
|
||||
></textarea>
|
||||
<button class="primary-button" type="submit" disabled={!canCommit}>
|
||||
{#if operation === "Committing"}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Commit
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="commit-hint">
|
||||
<strong>{stagedCount}</strong>
|
||||
<span>files staged</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="file-history-panel" aria-label="Selected file history">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="eyebrow">{selectedExplorerLabel}</span>
|
||||
<h2>{selectedExplorerPath || "No file"}</h2>
|
||||
</div>
|
||||
<span class="counter">{fileHistory.length}</span>
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
<div class="blank-state">No repository loaded.</div>
|
||||
{:else if !selectedExplorerPath}
|
||||
<div class="blank-state">Select a file in Explorer.</div>
|
||||
{:else if fileHistory.length === 0}
|
||||
<div class="blank-state">No history returned for this selection.</div>
|
||||
{:else}
|
||||
<div class="history-list">
|
||||
{#each fileHistory as item (item.hash)}
|
||||
<article class="commit-row compact">
|
||||
<div class="commit-line">
|
||||
<History size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<strong title={item.summary}>{item.summary}</strong>
|
||||
<span>{item.short_hash} - {item.author_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="commit-actions">
|
||||
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => restoreSelectedFileFromCommit(item)}
|
||||
disabled={isBusy}
|
||||
title="Restore selected file from this commit"
|
||||
>
|
||||
<RotateCcw size={15} aria-hidden="true" />
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="history-panel" aria-label="Commit history">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="eyebrow">History</span>
|
||||
<h2>Commits</h2>
|
||||
</div>
|
||||
<span class="counter">{commits.length}</span>
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
<div class="blank-state">No repository loaded.</div>
|
||||
{:else if commits.length === 0}
|
||||
<div class="blank-state">No commits returned.</div>
|
||||
{:else}
|
||||
<div class="history-list">
|
||||
{#each commits as item (item.hash)}
|
||||
<article class="commit-row">
|
||||
<div class="commit-line">
|
||||
<History size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<strong title={item.summary}>{item.summary}</strong>
|
||||
<span>{item.short_hash} - {item.author_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if item.refs.length > 0}
|
||||
<div class="ref-list" aria-label="Commit refs">
|
||||
{#each item.refs as ref}
|
||||
<span>{ref}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if item.files.length > 0}
|
||||
<div class="commit-file-list" aria-label="Changed files">
|
||||
{#each item.files as file (`${item.hash}:${file.old_path ?? ""}:${file.path}`)}
|
||||
<button
|
||||
type="button"
|
||||
class="commit-file-button"
|
||||
onclick={() => restoreCommitFile(item, file)}
|
||||
disabled={isBusy}
|
||||
title="Restore this file from this commit"
|
||||
>
|
||||
<span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span>
|
||||
<strong>{displayCommitFile(file)}</strong>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="commit-actions">
|
||||
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => restoreCommit(item)}
|
||||
disabled={isBusy}
|
||||
title="Reset current branch to this commit"
|
||||
>
|
||||
<RotateCcw size={15} aria-hidden="true" />
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
+834
@@ -0,0 +1,834 @@
|
||||
:root {
|
||||
color: #202326;
|
||||
background: #eef1f3;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #c7ced4;
|
||||
border-radius: 6px;
|
||||
color: #202326;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 140ms ease,
|
||||
border-color 140ms ease,
|
||||
color 140ms ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
border-color: #8fa0ad;
|
||||
background: #f6f8f9;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #c7ced4;
|
||||
border-radius: 6px;
|
||||
color: #202326;
|
||||
background: #ffffff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
border-color: #2f7da1;
|
||||
box-shadow: 0 0 0 3px rgba(47, 125, 161, 0.14);
|
||||
}
|
||||
|
||||
input {
|
||||
height: 34px;
|
||||
padding: 0 11px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 132px;
|
||||
padding: 10px 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
min-height: 54px;
|
||||
padding: 10px;
|
||||
border: 1px solid #d2d8de;
|
||||
border-radius: 8px;
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.repo-form {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(280px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.repo-form label {
|
||||
color: #54606a;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
border-color: #256f8f;
|
||||
color: #ffffff;
|
||||
background: #256f8f;
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled) {
|
||||
border-color: #1f5f7b;
|
||||
background: #1f5f7b;
|
||||
}
|
||||
|
||||
.notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
padding: 8px 11px;
|
||||
border: 1px solid;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.notice.error {
|
||||
border-color: #e8aaa4;
|
||||
color: #8e2d27;
|
||||
background: #fff4f2;
|
||||
}
|
||||
|
||||
.notice.busy {
|
||||
border-color: #a7cfe2;
|
||||
color: #245d78;
|
||||
background: #edf8fd;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 340px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.left-sidebar {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(220px, 0.9fr) minmax(260px, 1.1fr);
|
||||
min-height: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.branches,
|
||||
.explorer-panel,
|
||||
.main-panel,
|
||||
.status-panel,
|
||||
.commit-panel,
|
||||
.file-history-panel,
|
||||
.history-panel {
|
||||
min-height: 0;
|
||||
border: 1px solid #d2d8de;
|
||||
border-radius: 8px;
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.branches,
|
||||
.explorer-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 58px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #dce1e5;
|
||||
}
|
||||
|
||||
.section-heading h2,
|
||||
.repo-summary h1 {
|
||||
margin: 1px 0 0;
|
||||
color: #202326;
|
||||
}
|
||||
|
||||
.section-heading h2 {
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.repo-summary h1 {
|
||||
font-size: 24px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #697681;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.counter,
|
||||
.active-pill,
|
||||
.status-counts span,
|
||||
.sync-stats strong,
|
||||
.sync-stats span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.counter,
|
||||
.status-counts span,
|
||||
.sync-stats span {
|
||||
color: #4f5d66;
|
||||
background: #edf0f2;
|
||||
}
|
||||
|
||||
.active-pill {
|
||||
color: #176239;
|
||||
background: #e4f4ea;
|
||||
}
|
||||
|
||||
.branch-list,
|
||||
.explorer-list,
|
||||
.file-list,
|
||||
.history-list {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.branch-list,
|
||||
.explorer-list {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.branch-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
padding: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.branch-row + .branch-row {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.branch-row.current {
|
||||
border-color: #b9ddc6;
|
||||
background: #f0f8f3;
|
||||
}
|
||||
|
||||
.branch-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.branch-info div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.branch-info strong,
|
||||
.file-title strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.branch-info strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.branch-info span {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: #697681;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.branch-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.branch-actions button {
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.explorer-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
justify-content: stretch;
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 5px 8px 5px calc(8px + var(--depth, 0) * 14px);
|
||||
border: 1px solid #c7ced4;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
text-align: left;
|
||||
transition:
|
||||
background 140ms ease,
|
||||
border-color 140ms ease;
|
||||
}
|
||||
|
||||
.explorer-row + .explorer-row {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.explorer-row:hover {
|
||||
border-color: #8fa0ad;
|
||||
background: #f6f8f9;
|
||||
}
|
||||
|
||||
.explorer-row.active {
|
||||
border-color: #9fc8dc;
|
||||
background: #edf8fd;
|
||||
}
|
||||
|
||||
.explorer-row.folder {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.explorer-row svg {
|
||||
color: #4f7d96;
|
||||
}
|
||||
|
||||
.tree-toggle,
|
||||
.explorer-select {
|
||||
min-height: 22px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tree-toggle {
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.tree-toggle:hover:not(:disabled),
|
||||
.explorer-select:hover:not(:disabled) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.explorer-select {
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tree-spacer {
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.explorer-select span {
|
||||
overflow: hidden;
|
||||
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.main-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.repo-summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #dce1e5;
|
||||
}
|
||||
|
||||
.repo-summary p {
|
||||
overflow: hidden;
|
||||
margin: 5px 0 0;
|
||||
color: #5f6b75;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sync-stats,
|
||||
.status-counts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sync-stats strong:first-of-type {
|
||||
color: #8a4c0e;
|
||||
background: #fff2d7;
|
||||
}
|
||||
|
||||
.sync-stats strong:last-of-type {
|
||||
color: #255b8b;
|
||||
background: #e7f1fb;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
min-height: 0;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.status-panel,
|
||||
.commit-panel,
|
||||
.file-history-panel,
|
||||
.history-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.side-stack {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(210px, 0.75fr) minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid #dce1e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.file-row + .file-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.file-title strong {
|
||||
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.change-lanes {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.change-lane {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 7px;
|
||||
border: 1px solid #dce1e5;
|
||||
border-radius: 6px;
|
||||
background: #f7f9fa;
|
||||
}
|
||||
|
||||
.change-lane.inactive {
|
||||
color: #8a949c;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.lane-name {
|
||||
color: #596670;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.lane-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.lane-actions button {
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.quiet {
|
||||
overflow: hidden;
|
||||
color: #7d8891;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
min-width: 74px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge.modified {
|
||||
color: #7b4a05;
|
||||
background: #ffe8bb;
|
||||
}
|
||||
|
||||
.status-badge.added,
|
||||
.status-badge.untracked {
|
||||
color: #176239;
|
||||
background: #dff3e7;
|
||||
}
|
||||
|
||||
.status-badge.deleted {
|
||||
color: #9b2e29;
|
||||
background: #ffe1df;
|
||||
}
|
||||
|
||||
.status-badge.renamed {
|
||||
color: #255b8b;
|
||||
background: #e0effd;
|
||||
}
|
||||
|
||||
.status-badge.conflicted {
|
||||
color: #8f2d61;
|
||||
background: #fde2f0;
|
||||
}
|
||||
|
||||
.status-badge.unknown,
|
||||
.status-badge.none {
|
||||
color: #596670;
|
||||
background: #e9edef;
|
||||
}
|
||||
|
||||
.commit-panel {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.commit-form {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.commit-form .primary-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.commit-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 12px 12px;
|
||||
padding: 10px;
|
||||
border: 1px solid #dce1e5;
|
||||
border-radius: 8px;
|
||||
color: #596670;
|
||||
background: #f7f9fa;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.commit-hint strong {
|
||||
color: #202326;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.commit-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid #dce1e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.commit-row + .commit-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.commit-row.compact {
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
.commit-line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.commit-line svg {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 2px;
|
||||
color: #4f7d96;
|
||||
}
|
||||
|
||||
.commit-line div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.commit-line strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: #202326;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.commit-line span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin-top: 3px;
|
||||
color: #697681;
|
||||
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ref-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.ref-list span {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
color: #255b8b;
|
||||
background: #e7f1fb;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.commit-file-list {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.commit-file-button {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
justify-content: stretch;
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
padding: 5px 7px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.commit-file-button strong {
|
||||
overflow: hidden;
|
||||
color: #202326;
|
||||
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.commit-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.commit-actions time {
|
||||
overflow: hidden;
|
||||
color: #6c7882;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.commit-actions button {
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.blank-state,
|
||||
.empty-note {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 120px;
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
color: #6c7882;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-note {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.workspace {
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.commit-panel {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.side-stack {
|
||||
min-height: 760px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.repo-form,
|
||||
.workspace,
|
||||
.repo-summary,
|
||||
.change-lanes {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.sync-stats {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.branch-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.branches {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.left-sidebar {
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.change-lane {
|
||||
grid-template-columns: auto auto;
|
||||
}
|
||||
|
||||
.lane-actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { GitBranch, GitCommit, GitRepositoryFile, GitStatus } from "./types";
|
||||
|
||||
export function openRepository(path: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("open_repository", { path });
|
||||
}
|
||||
|
||||
export function getStatus(path: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("get_status", { path });
|
||||
}
|
||||
|
||||
export function listBranches(path: string): Promise<GitBranch[]> {
|
||||
return invoke<GitBranch[]>("list_branches", { path });
|
||||
}
|
||||
|
||||
export function checkoutBranch(path: string, branch: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("checkout_branch", { path, branch });
|
||||
}
|
||||
|
||||
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("stage_files", { path, files });
|
||||
}
|
||||
|
||||
export function unstageFiles(path: string, files: string[]): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("unstage_files", { path, files });
|
||||
}
|
||||
|
||||
export function restoreFiles(
|
||||
path: string,
|
||||
files: string[],
|
||||
staged: boolean,
|
||||
): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("restore_files", { path, files, staged });
|
||||
}
|
||||
|
||||
export function commit(path: string, message: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("commit", { path, message });
|
||||
}
|
||||
|
||||
export function pull(path: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("pull", { path });
|
||||
}
|
||||
|
||||
export function push(path: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("push", { path });
|
||||
}
|
||||
|
||||
export function listCommits(path: string, limit = 100): Promise<GitCommit[]> {
|
||||
return invoke<GitCommit[]>("list_commits", { path, limit });
|
||||
}
|
||||
|
||||
export function restoreToCommit(path: string, commit: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("restore_to_commit", { path, commit });
|
||||
}
|
||||
|
||||
export function restoreFileFromCommit(
|
||||
path: string,
|
||||
commit: string,
|
||||
file: string,
|
||||
): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("restore_file_from_commit", { path, commit, file });
|
||||
}
|
||||
|
||||
export function mergeBranch(path: string, branch: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("merge_branch", { path, branch });
|
||||
}
|
||||
|
||||
export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]> {
|
||||
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
|
||||
}
|
||||
|
||||
export function listFileHistory(path: string, file: string, limit = 100): Promise<GitCommit[]> {
|
||||
return invoke<GitCommit[]>("list_file_history", { path, file, limit });
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export type FileStatusKind =
|
||||
| "modified"
|
||||
| "added"
|
||||
| "deleted"
|
||||
| "renamed"
|
||||
| "untracked"
|
||||
| "conflicted"
|
||||
| "unknown";
|
||||
|
||||
export interface GitStatus {
|
||||
repo_path: string;
|
||||
current_branch: string | null;
|
||||
upstream: string | null;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
files: GitFileStatus[];
|
||||
clean: boolean;
|
||||
}
|
||||
|
||||
export interface GitFileStatus {
|
||||
path: string;
|
||||
old_path: string | null;
|
||||
staged: FileStatusKind | null;
|
||||
unstaged: FileStatusKind | null;
|
||||
}
|
||||
|
||||
export interface GitBranch {
|
||||
name: string;
|
||||
current: boolean;
|
||||
remote: boolean;
|
||||
}
|
||||
|
||||
export interface GitCommit {
|
||||
hash: string;
|
||||
short_hash: string;
|
||||
summary: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
date: string;
|
||||
refs: string[];
|
||||
files: GitCommitFile[];
|
||||
}
|
||||
|
||||
export interface GitCommitFile {
|
||||
path: string;
|
||||
old_path: string | null;
|
||||
status: FileStatusKind;
|
||||
}
|
||||
|
||||
export interface GitRepositoryFile {
|
||||
path: string;
|
||||
tracked: boolean;
|
||||
status: FileStatusKind | null;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { mount } from "svelte";
|
||||
|
||||
import App from "./App.svelte";
|
||||
import "./app.css";
|
||||
|
||||
const target = document.getElementById("app");
|
||||
|
||||
if (!target) {
|
||||
throw new Error("App target element was not found.");
|
||||
}
|
||||
|
||||
export default mount(App, { target });
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
compilerOptions: {
|
||||
dev: process.env.NODE_ENV === "development"
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"isolatedModules": true,
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["svelte"]
|
||||
},
|
||||
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"target": "ES2020",
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
strictPort: true,
|
||||
port: 1420,
|
||||
},
|
||||
envPrefix: ["VITE_", "TAURI_"],
|
||||
build: {
|
||||
target: process.env.TAURI_ENV_PLATFORM === "windows" ? "chrome105" : "safari13",
|
||||
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
|
||||
sourcemap: Boolean(process.env.TAURI_ENV_DEBUG),
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user