Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f160e48777 | ||
|
|
4533f8aa38 | ||
|
|
f0bd74be4e | ||
|
|
79f4ec21e4 | ||
|
|
44547f507a | ||
|
|
58027f5e22 | ||
|
|
3477070ec3 | ||
|
|
7d3288a7ef | ||
|
|
6d806e87ea | ||
|
|
7408371430 | ||
|
|
e4697c74b6 | ||
|
|
a4936ba790 | ||
|
|
b5d15f9190 | ||
|
|
e7fbaadf5e | ||
|
|
8b559fa15c | ||
|
|
fac22ff173 | ||
|
|
cd01df0108 | ||
|
|
6f2d4dd8c9 | ||
|
|
33e85fe6be | ||
|
|
993179c25e |
@@ -161,7 +161,7 @@ jobs:
|
||||
>> /etc/pacman.d/mirrorlist
|
||||
|
||||
ARCH_PACKAGES=(
|
||||
base-devel curl git nodejs npm openssh rust
|
||||
base-devel curl git git-lfs nodejs npm openssh rust
|
||||
webkit2gtk-4.1 gtk3 hicolor-icon-theme
|
||||
libappindicator-gtk3 librsvg xdotool
|
||||
)
|
||||
@@ -486,7 +486,7 @@ jobs:
|
||||
- name: Install Ubuntu dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential curl wget file libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
|
||||
sudo apt-get install -y build-essential curl wget file git-lfs libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev libfuse2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
+4
-1
@@ -1,6 +1,9 @@
|
||||
/node_modules
|
||||
/dist
|
||||
/src-tauri/target
|
||||
/src-tauri/binaries/git-lfs
|
||||
/src-tauri/binaries/git-lfs.exe
|
||||
/src-tauri/binaries/git-lfs-*-*-*
|
||||
*.log
|
||||
.idea
|
||||
.DS_Store
|
||||
@@ -9,4 +12,4 @@
|
||||
target
|
||||
|
||||
*.pkg.tar.zst
|
||||
pkg
|
||||
pkg
|
||||
|
||||
@@ -4,6 +4,69 @@ All notable user-facing changes to Gitty are documented in this file.
|
||||
|
||||
The project uses calendar-style versions in the form `YYYY.M.PATCH`.
|
||||
|
||||
## [2026.8.6] - 2026-08-18
|
||||
|
||||
### Changed
|
||||
|
||||
- Activating LFS or adding a tracking pattern now ensures that a root
|
||||
`.gitattributes` file is not hidden by Git ignore rules. When necessary,
|
||||
Gitty adds the scoped `!/.gitattributes` exception to `.gitignore`.
|
||||
- The standard Tauri development launcher now removes an injected non-routing
|
||||
`127.0.0.1:9` proxy and blocking SSH placeholder from the debug child
|
||||
process, while preserving real user and company proxy settings.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Root `.gitattributes` LFS patterns remain visible while the file is
|
||||
untracked or ignored, instead of disappearing from the LFS dialog.
|
||||
- Git LFS pushes rejected with HTTP 413 are retried once with a command-scoped
|
||||
HTTP/1.1 override, which works around Azure DevOps' large HTTP/2 upload
|
||||
behavior without changing repository or global Git settings.
|
||||
- LFS and other generic push failures no longer trigger the unrelated
|
||||
Pull/Push retry flow intended only for non-fast-forward rejections.
|
||||
|
||||
## [2026.8.5] - 2026-08-17
|
||||
|
||||
### Added
|
||||
|
||||
- Integrated Git LFS management now detects the available extension and the
|
||||
repository setup, shows tracked patterns and checkout objects, and provides
|
||||
actions for local activation, tracking and untracking patterns, downloading
|
||||
objects, and pruning the local cache. Desktop installers bundle Git LFS,
|
||||
while Arch packages declare it as a dependency.
|
||||
- The Changes panel now offers a List/Tree switch. Tree view groups staged and
|
||||
unstaged files into independently collapsible folders while retaining all
|
||||
existing file actions.
|
||||
- Files and folders in the Changes panel now have context-menu actions for
|
||||
staging or unstaging their scope and for creating a stash containing only
|
||||
the selected file or folder. New and untracked items can also be added to
|
||||
the repository `.gitignore` from Changes or the File Explorer as an exact
|
||||
file, a complete folder, or an extension-wide pattern. Folder rules are only
|
||||
offered for folder selections. Tracked files and folders can be removed from
|
||||
the Git index without deleting their working-tree contents.
|
||||
- Gitty can open a repository directly at startup through the `--repo PATH` or
|
||||
`--repo=PATH` command-line argument.
|
||||
- The executable can clone and immediately open a repository with
|
||||
`clone REMOTE TARGET`, `--clone REMOTE TARGET`, or `--clone=REMOTE TARGET`.
|
||||
Relative targets use the caller's working directory, and requests are also
|
||||
forwarded to an already-running Gitty instance.
|
||||
|
||||
### Changed
|
||||
|
||||
- Successful clones and pulls now detect repositories that use LFS and
|
||||
download the required LFS objects automatically with the same remote and
|
||||
credentials. Fresh clones also activate LFS locally before they are opened.
|
||||
- Unstaged and staged changes use an equal-width side-by-side layout with
|
||||
independent scrolling, directional stage/unstage actions, and a responsive
|
||||
vertical fallback. The List/Tree switch is centered above both areas.
|
||||
- File and folder context menus now separate the selected name from its parent
|
||||
path, show the affected file count, and present stage, unstage, and stash
|
||||
actions with clearer icons and descriptions.
|
||||
- Controls and surfaces use a more consistent square visual language while
|
||||
circular status markers, avatars, and branch shapes remain recognizable.
|
||||
- The built-in German and English help now includes Git LFS setup, tracking,
|
||||
troubleshooting, and everyday workflow guidance.
|
||||
|
||||
## [2026.8.4] - 2026-08-15
|
||||
|
||||
### Added
|
||||
@@ -180,6 +243,8 @@ The project uses calendar-style versions in the form `YYYY.M.PATCH`.
|
||||
[2026.07.22]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.22
|
||||
[2026.07.21]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.21
|
||||
[2026.7.20]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.20
|
||||
[2026.8.6]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.6
|
||||
[2026.8.5]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.5
|
||||
[2026.8.4]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.4
|
||||
[2026.8.3]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.3
|
||||
[2026.8.2]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.2
|
||||
|
||||
@@ -7,7 +7,7 @@ pkgdesc="A lightweight, modern Git client built with Tauri"
|
||||
arch=('x86_64')
|
||||
url="https://git.cbsk-tech.de/Christoph/GitLite"
|
||||
license=('MIT')
|
||||
depends=('webkit2gtk-4.1' 'gtk3' 'git' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
|
||||
depends=('webkit2gtk-4.1' 'gtk3' 'git' 'git-lfs' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
|
||||
makedepends=('rust' 'nodejs' 'npm')
|
||||
options=('!lto' '!debug')
|
||||
|
||||
|
||||
+2
-2
@@ -1,13 +1,13 @@
|
||||
# Maintainer: Christoph Brandau <c.brandau91@googlemail.com>
|
||||
|
||||
pkgname=gitty-desktop-bin
|
||||
pkgver=2026.8.4
|
||||
pkgver=2026.8.6
|
||||
pkgrel=1
|
||||
pkgdesc="A lightweight, modern Git client built with Tauri (prebuilt Arch package)"
|
||||
arch=('x86_64')
|
||||
url="https://git.cbsk-tech.de/Christoph/GitLite"
|
||||
license=('MIT')
|
||||
depends=('git' 'webkit2gtk-4.1' 'gtk3' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
|
||||
depends=('git' 'git-lfs' 'webkit2gtk-4.1' 'gtk3' 'hicolor-icon-theme' 'libappindicator-gtk3' 'librsvg' 'xdotool')
|
||||
provides=('gitty-desktop')
|
||||
conflicts=('gitty-desktop')
|
||||
options=('!strip')
|
||||
|
||||
@@ -20,6 +20,7 @@ Fast, simple, and designed for developers who want a clean Git experience withou
|
||||
- 🔄 Pull, Push & Fetch
|
||||
- 🔀 Merge & Rebase
|
||||
- 📦 Repository management
|
||||
- 🗄️ Git LFS detection, tracking and object management
|
||||
- 🎨 Modern and intuitive UI
|
||||
|
||||
---
|
||||
@@ -81,6 +82,55 @@ files.
|
||||
|
||||
---
|
||||
|
||||
## Command line
|
||||
|
||||
Open an existing repository when Gitty starts:
|
||||
|
||||
```text
|
||||
gitty.exe --repo "D:\Projects\ExistingRepo"
|
||||
```
|
||||
|
||||
Clone a remote into an exact local target folder and open it immediately:
|
||||
|
||||
```text
|
||||
gitty.exe --clone "https://example.com/team/project.git" "D:\Projects\Project"
|
||||
gitty clone "git@example.com:team/project.git" "D:\Projects\Project"
|
||||
```
|
||||
|
||||
`--clone=<REMOTE>` is also accepted. Relative target paths are resolved from
|
||||
the current working directory. Clone and repository requests are forwarded to
|
||||
the running window when Gitty is already open.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
Start the complete desktop application in development mode with:
|
||||
|
||||
```bash
|
||||
npm run tauri:dev
|
||||
```
|
||||
|
||||
The development launcher preserves normal proxy settings. If a sandboxed
|
||||
development terminal injects the non-routing `127.0.0.1:9` proxy or its
|
||||
blocking SSH placeholder, those values are removed only for the Tauri child
|
||||
process so Git remotes and Git LFS remain testable in the debug application.
|
||||
|
||||
---
|
||||
|
||||
## Git LFS
|
||||
|
||||
Gitty bundles the `git-lfs` executable in its desktop installers and checks it
|
||||
at runtime before offering LFS actions. Arch packages also declare `git-lfs` as
|
||||
a dependency so Git hooks and command-line workflows outside Gitty use the same
|
||||
extension. The repository toolbar exposes LFS setup, tracked patterns, object
|
||||
downloads, and safe cache pruning. After every successful clone or pull, Gitty
|
||||
detects LFS usage and automatically downloads the required LFS objects with the
|
||||
same remote and credentials, so no second pull is needed. Fresh clones also get
|
||||
repository-local LFS filters and the pre-push hook before they are opened.
|
||||
|
||||
---
|
||||
|
||||
## SigNoz telemetry
|
||||
|
||||
After the user opts in, Gitty sends privacy-filtered product events and technical errors as OTLP/HTTP JSON logs. Every Git/Tauri command is also captured as a trace span with its command name, duration and success state, but without command arguments or results. CPU utilization and resident memory usage of the Gitty process are sampled every 30 seconds and exported as OpenTelemetry gauges. The default endpoints are `https://telemetry.cbsk-tech.de/v1/logs`, `https://telemetry.cbsk-tech.de/v1/traces`, and `https://telemetry.cbsk-tech.de/v1/metrics`.
|
||||
|
||||
@@ -73,6 +73,34 @@ interface GitRepositoryFile {
|
||||
tracked: boolean;
|
||||
status: FileStatusKind | null;
|
||||
}
|
||||
|
||||
interface GitLfsStatus {
|
||||
available: boolean;
|
||||
bundled: boolean;
|
||||
version: string | null;
|
||||
filters_configured: boolean;
|
||||
hook_installed: boolean;
|
||||
repository_uses_lfs: boolean;
|
||||
patterns: GitLfsPattern[];
|
||||
files: GitLfsFile[];
|
||||
}
|
||||
|
||||
interface GitLfsPattern {
|
||||
pattern: string;
|
||||
source: string;
|
||||
lockable: boolean;
|
||||
tracked: boolean;
|
||||
}
|
||||
|
||||
interface GitLfsFile {
|
||||
name: string;
|
||||
size: number;
|
||||
checkout: boolean;
|
||||
downloaded: boolean;
|
||||
oid_type: string;
|
||||
oid: string;
|
||||
version: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
@@ -83,6 +111,14 @@ The command list below includes the repository-management and synchronization AP
|
||||
- `init_repository(path: string, initialBranch?: string): Promise<GitStatus>`
|
||||
- `clone_repository(...): Promise<RepositoryBundle>`
|
||||
- `get_status(path: string): Promise<GitStatus>`
|
||||
- `git_lfs_status(path: string): Promise<GitLfsStatus>`
|
||||
- `git_lfs_install(path: string): Promise<GitLfsStatus>`
|
||||
- `git_lfs_track(path: string, pattern: string, lockable?: boolean): Promise<GitLfsStatus>`
|
||||
- `git_lfs_untrack(path: string, pattern: string): Promise<GitLfsStatus>`
|
||||
- `git_lfs_pull(path: string, remote?: string, username?: string, password?: string): Promise<GitLfsStatus>`
|
||||
- `git_lfs_prune(path: string): Promise<GitLfsStatus>`
|
||||
- `pull(...)` automatically runs `git lfs pull` after a successful Git pull when
|
||||
the repository contains LFS attributes or tracked LFS objects.
|
||||
- `list_branches(path: string): Promise<GitBranch[]>`
|
||||
- `list_remotes(path: string): Promise<GitRemote[]>`
|
||||
- `add_remote(path: string, name: string, url: string): Promise<GitRemote[]>`
|
||||
@@ -101,7 +137,10 @@ The command list below includes the repository-management and synchronization AP
|
||||
- `repair_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
|
||||
- `stage_files(path: string, files: string[]): Promise<GitStatus>`
|
||||
- `unstage_files(path: string, files: string[]): Promise<GitStatus>`
|
||||
- `add_to_gitignore(path: string, target: string, kind: "file" | "extension" | "folder"): Promise<GitStatus>`; appends a repository-root `.gitignore` rule and unstages newly-added matching files.
|
||||
- `untrack_paths(path: string, targets: string[]): Promise<GitStatus>`; removes files or folders from the Git index while preserving their working-tree contents.
|
||||
- `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>`
|
||||
- `stash_push(path: string, message?: string, includeUntracked?: boolean, paths?: string[]): Promise<GitStatus>`; when `paths` is provided, only matching files are stashed.
|
||||
- `commit(path: string, message: string): Promise<GitStatus>`
|
||||
- `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>`
|
||||
- `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: string): Promise<GitStatus>`
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gitty",
|
||||
"version": "2026.8.4",
|
||||
"version": "2026.8.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitty",
|
||||
"version": "2026.8.4",
|
||||
"version": "2026.8.7",
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.21.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
|
||||
+3
-2
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "gitty",
|
||||
"version": "2026.8.4",
|
||||
"version": "2026.8.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build",
|
||||
"prepare:lfs": "node scripts/prepare-git-lfs-sidecar.mjs",
|
||||
"icons": "node scripts/generate-app-icons.mjs",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:dev": "node scripts/tauri-dev.mjs",
|
||||
"tauri:build": "tauri build",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { copyFileSync, existsSync, mkdirSync, chmodSync, statSync } from "node:fs";
|
||||
import { basename, delimiter, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = resolve(scriptDirectory, "..");
|
||||
const binariesDirectory = join(projectRoot, "src-tauri", "binaries");
|
||||
const extension = process.platform === "win32" ? ".exe" : "";
|
||||
|
||||
function defaultTargetTriple() {
|
||||
const triples = {
|
||||
"win32:x64": "x86_64-pc-windows-msvc",
|
||||
"win32:arm64": "aarch64-pc-windows-msvc",
|
||||
"linux:x64": "x86_64-unknown-linux-gnu",
|
||||
"linux:arm64": "aarch64-unknown-linux-gnu",
|
||||
"darwin:x64": "x86_64-apple-darwin",
|
||||
"darwin:arm64": "aarch64-apple-darwin",
|
||||
};
|
||||
return triples[`${process.platform}:${process.arch}`];
|
||||
}
|
||||
|
||||
function executableCandidates() {
|
||||
const configured = process.env.GIT_LFS_BINARY?.trim();
|
||||
if (configured) return [configured];
|
||||
|
||||
const pathEntries = (process.env.PATH ?? "")
|
||||
.split(delimiter)
|
||||
.filter(Boolean);
|
||||
const candidates = pathEntries.map((entry) => join(entry, `git-lfs${extension}`));
|
||||
if (process.platform === "win32") {
|
||||
for (const entry of pathEntries) {
|
||||
if (basename(entry).toLowerCase() === "cmd") {
|
||||
candidates.push(resolve(entry, "..", "mingw64", "bin", "git-lfs.exe"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
const source = executableCandidates()
|
||||
.filter((candidate) => existsSync(candidate))
|
||||
// Git for Windows exposes a small launcher in cmd/ and the standalone
|
||||
// executable in mingw64/bin/. The standalone file is the portable sidecar.
|
||||
.sort((left, right) => statSync(right).size - statSync(left).size)[0];
|
||||
if (!source) {
|
||||
throw new Error(
|
||||
"Git LFS was not found. Install git-lfs or set GIT_LFS_BINARY before building Gitty.",
|
||||
);
|
||||
}
|
||||
|
||||
const targetTriple = (
|
||||
process.env.GITTY_TARGET_TRIPLE
|
||||
?? process.env.TAURI_ENV_TARGET_TRIPLE
|
||||
?? process.env.TARGET
|
||||
?? defaultTargetTriple()
|
||||
)?.trim();
|
||||
if (!targetTriple) throw new Error("Rust did not report a target triple.");
|
||||
|
||||
mkdirSync(binariesDirectory, { recursive: true });
|
||||
|
||||
const developmentTarget = join(binariesDirectory, `git-lfs${extension}`);
|
||||
const bundleTarget = join(
|
||||
binariesDirectory,
|
||||
`git-lfs-${targetTriple}${extension}`,
|
||||
);
|
||||
for (const target of [developmentTarget, bundleTarget]) {
|
||||
if (resolve(source) !== resolve(target)) copyFileSync(source, target);
|
||||
if (process.platform !== "win32") chmodSync(target, 0o755);
|
||||
}
|
||||
|
||||
console.log(`Prepared bundled git-lfs from ${source} for ${targetTriple}.`);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const debugEnvironment = { ...process.env };
|
||||
const clearedVariables = [];
|
||||
const blockedProxy = /^https?:\/\/127\.0\.0\.1:9\/?$/i;
|
||||
const proxyVariables = new Set([
|
||||
"all_proxy",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"git_http_proxy",
|
||||
"git_https_proxy",
|
||||
]);
|
||||
|
||||
for (const [name, value] of Object.entries(debugEnvironment)) {
|
||||
if (proxyVariables.has(name.toLowerCase()) && blockedProxy.test(value ?? "")) {
|
||||
delete debugEnvironment[name];
|
||||
clearedVariables.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, value] of Object.entries(debugEnvironment)) {
|
||||
if (
|
||||
name.toLowerCase() === "git_ssh_command"
|
||||
&& /^cmd(?:\.exe)?\s+\/c\s+exit\s+1$/i.test((value ?? "").trim())
|
||||
) {
|
||||
delete debugEnvironment[name];
|
||||
clearedVariables.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv.includes("--check-environment")) {
|
||||
process.stdout.write(
|
||||
clearedVariables.length > 0
|
||||
? `Debug environment ready; cleared: ${clearedVariables.sort().join(", ")}\n`
|
||||
: "Debug environment ready; no blocked proxy variables found.\n",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (clearedVariables.length > 0) {
|
||||
process.stdout.write(
|
||||
`Starting Gitty without the blocked debug proxy (${clearedVariables.sort().join(", ")}).\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
const child = spawn(isWindows ? "tauri.cmd" : "tauri", ["dev"], {
|
||||
cwd: process.cwd(),
|
||||
env: debugEnvironment,
|
||||
stdio: "inherit",
|
||||
shell: isWindows,
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
process.stderr.write(`Could not start Tauri development mode: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
child.on("exit", (code) => {
|
||||
process.exitCode = code ?? 1;
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014- GitHub, Inc. and Git LFS contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Portions of the subprocess and tools directories are copied from Go and are
|
||||
under the following license:
|
||||
|
||||
Copyright (c) 2009,2010 The Go Authors. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
* Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
* Neither the name of Google Inc. nor the names of its contributors may be
|
||||
used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
+1393
-27
File diff suppressed because it is too large
Load Diff
+219
-7
@@ -10,14 +10,15 @@ use external_tools::{
|
||||
detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool,
|
||||
};
|
||||
use git::{
|
||||
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
|
||||
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
||||
SearchCancellationState, add_remote, add_to_gitignore, add_worktree, amend_commit,
|
||||
apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
||||
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
|
||||
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
|
||||
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
|
||||
cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch,
|
||||
delete_remote_branches, delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes,
|
||||
get_commit_note, get_file_blame, get_file_patch, get_remote_url, get_status, init_repository,
|
||||
get_commit_note, get_file_blame, get_file_patch, get_remote_url, get_status, git_lfs_install,
|
||||
git_lfs_prune, git_lfs_pull, git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository,
|
||||
last_commit_message, list_branches, list_commits, list_file_history,
|
||||
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
|
||||
list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch,
|
||||
@@ -29,11 +30,193 @@ use git::{
|
||||
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
|
||||
set_branch_upstream, set_commit_note, stage_files, start_interactive_rebase, stash_apply,
|
||||
stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files,
|
||||
update_remote,
|
||||
untrack_paths, update_remote,
|
||||
};
|
||||
use tauri::Manager;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{Emitter, Manager};
|
||||
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
|
||||
|
||||
struct StartupRepository(Mutex<Option<String>>);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct StartupCloneRequest {
|
||||
remote_url: String,
|
||||
parent_path: String,
|
||||
directory_name: String,
|
||||
}
|
||||
|
||||
struct StartupClone(Mutex<Option<StartupCloneRequest>>);
|
||||
|
||||
fn resolve_startup_path(path: &str, cwd: &Path) -> Option<PathBuf> {
|
||||
let path = path.trim();
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let path = PathBuf::from(path);
|
||||
Some(if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
cwd.join(path)
|
||||
})
|
||||
}
|
||||
|
||||
fn clone_request_from_args(
|
||||
args: impl IntoIterator<Item = String>,
|
||||
cwd: &Path,
|
||||
) -> Option<StartupCloneRequest> {
|
||||
let mut args = args.into_iter();
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
let remote_url = if arg == "clone" || arg == "--clone" {
|
||||
args.next()
|
||||
} else {
|
||||
arg.strip_prefix("--clone=").map(ToString::to_string)
|
||||
};
|
||||
let Some(remote_url) = remote_url.filter(|value| !value.trim().is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let target = resolve_startup_path(&args.next()?, cwd)?;
|
||||
let directory_name = target.file_name()?.to_string_lossy().trim().to_string();
|
||||
let parent_path = target.parent()?.to_string_lossy().into_owned();
|
||||
if directory_name.is_empty() || parent_path.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(StartupCloneRequest {
|
||||
remote_url,
|
||||
parent_path,
|
||||
directory_name,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn repository_path_from_args(args: impl IntoIterator<Item = String>, cwd: &Path) -> Option<String> {
|
||||
let mut args = args.into_iter();
|
||||
let mut repository = None;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "clone" || arg == "--clone" || arg.starts_with("--clone=") {
|
||||
return None;
|
||||
}
|
||||
if arg == "--repo" {
|
||||
repository = args.next();
|
||||
break;
|
||||
}
|
||||
if let Some(path) = arg.strip_prefix("--repo=") {
|
||||
repository = Some(path.to_string());
|
||||
break;
|
||||
}
|
||||
if !arg.starts_with('-') {
|
||||
repository = Some(arg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
repository
|
||||
.and_then(|path| resolve_startup_path(&path, cwd))
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_repository_tests {
|
||||
use super::{clone_request_from_args, repository_path_from_args};
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn accepts_direct_relative_repository_path() {
|
||||
let path =
|
||||
repository_path_from_args(["projects/repo".to_string()], Path::new("/home/user"));
|
||||
assert_eq!(
|
||||
path.map(|value| value.replace('\\', "/")),
|
||||
Some("/home/user/projects/repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_repo_option() {
|
||||
let path = repository_path_from_args(
|
||||
["--repo".to_string(), "/projects/repo".to_string()],
|
||||
Path::new("/home/user"),
|
||||
);
|
||||
assert_eq!(path.as_deref(), Some("/projects/repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_repo_equals_option() {
|
||||
let path = repository_path_from_args(
|
||||
["--repo=projects/repo".to_string()],
|
||||
Path::new("/home/user"),
|
||||
);
|
||||
assert_eq!(
|
||||
path.map(|value| value.replace('\\', "/")),
|
||||
Some("/home/user/projects/repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_clone_command_with_exact_relative_target() {
|
||||
let request = clone_request_from_args(
|
||||
[
|
||||
"clone".to_string(),
|
||||
"https://example.com/team/project.git".to_string(),
|
||||
"clones/local-copy".to_string(),
|
||||
],
|
||||
Path::new("/home/user"),
|
||||
)
|
||||
.expect("clone request should be parsed");
|
||||
|
||||
assert_eq!(request.remote_url, "https://example.com/team/project.git");
|
||||
assert_eq!(request.directory_name, "local-copy");
|
||||
assert_eq!(request.parent_path.replace('\\', "/"), "/home/user/clones");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_clone_option_and_clone_equals_option() {
|
||||
for args in [
|
||||
vec![
|
||||
"--clone".to_string(),
|
||||
"git@example.com:team/project.git".to_string(),
|
||||
"/projects/project".to_string(),
|
||||
],
|
||||
vec![
|
||||
"--clone=git@example.com:team/project.git".to_string(),
|
||||
"/projects/project".to_string(),
|
||||
],
|
||||
] {
|
||||
let request = clone_request_from_args(args, Path::new("/home/user"))
|
||||
.expect("clone option should be parsed");
|
||||
assert_eq!(request.directory_name, "project");
|
||||
assert_eq!(request.parent_path.replace('\\', "/"), "/projects");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_parser_does_not_treat_clone_values_as_repository_paths() {
|
||||
let path = repository_path_from_args(
|
||||
[
|
||||
"--clone".to_string(),
|
||||
"https://example.com/team/project.git".to_string(),
|
||||
"clones/project".to_string(),
|
||||
],
|
||||
Path::new("/home/user"),
|
||||
);
|
||||
assert!(path.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn take_startup_repository(state: tauri::State<'_, StartupRepository>) -> Option<String> {
|
||||
state.0.lock().ok()?.take()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn take_startup_clone(state: tauri::State<'_, StartupClone>) -> Option<StartupCloneRequest> {
|
||||
state.0.lock().ok()?.take()
|
||||
}
|
||||
|
||||
struct ConsoleLogger;
|
||||
|
||||
impl log::Log for ConsoleLogger {
|
||||
@@ -96,8 +279,25 @@ async fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
let startup_args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let startup_cwd = std::env::current_dir().unwrap_or_default();
|
||||
let startup_clone = clone_request_from_args(startup_args.clone(), &startup_cwd);
|
||||
let startup_repository = repository_path_from_args(startup_args, &startup_cwd);
|
||||
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
|
||||
let args: Vec<String> = args.into_iter().skip(1).collect();
|
||||
if let Some(request) = clone_request_from_args(args.clone(), Path::new(&cwd)) {
|
||||
if let Ok(mut pending) = app.state::<StartupClone>().0.lock() {
|
||||
*pending = Some(request);
|
||||
}
|
||||
let _ = app.emit("open-startup-repository", ());
|
||||
} else if let Some(path) = repository_path_from_args(args, Path::new(&cwd)) {
|
||||
if let Ok(mut pending) = app.state::<StartupRepository>().0.lock() {
|
||||
*pending = Some(path);
|
||||
}
|
||||
let _ = app.emit("open-startup-repository", ());
|
||||
}
|
||||
#[cfg(desktop)]
|
||||
let _ = app
|
||||
.get_webview_window("main")
|
||||
@@ -112,6 +312,8 @@ async fn main() {
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.manage(StartupRepository(Mutex::new(startup_repository)))
|
||||
.manage(StartupClone(Mutex::new(startup_clone)))
|
||||
.manage(SearchCancellationState::default())
|
||||
.manage(commit_ai::CommitAiEngine::new())
|
||||
.plugin(tauri_plugin_dialog::init());
|
||||
@@ -134,6 +336,12 @@ async fn main() {
|
||||
launch_external_diff,
|
||||
launch_external_merge,
|
||||
get_status,
|
||||
git_lfs_status,
|
||||
git_lfs_install,
|
||||
git_lfs_track,
|
||||
git_lfs_untrack,
|
||||
git_lfs_pull,
|
||||
git_lfs_prune,
|
||||
list_branches,
|
||||
list_remotes,
|
||||
add_remote,
|
||||
@@ -165,6 +373,8 @@ async fn main() {
|
||||
cherry_pick_abort,
|
||||
stage_files,
|
||||
unstage_files,
|
||||
add_to_gitignore,
|
||||
untrack_paths,
|
||||
stash_push,
|
||||
stash_apply,
|
||||
stash_pop,
|
||||
@@ -226,7 +436,9 @@ async fn main() {
|
||||
close_splashscreen,
|
||||
set_telemetry_enabled,
|
||||
emit_frontend_log,
|
||||
emit_frontend_span
|
||||
emit_frontend_span,
|
||||
take_startup_repository,
|
||||
take_startup_clone
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Gitty",
|
||||
"version": "2026.8.4",
|
||||
"version": "2026.8.7",
|
||||
"identifier": "com.gitty",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeDevCommand": "npm run prepare:lfs && npm run dev",
|
||||
"devUrl": "http://127.0.0.1:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"beforeBuildCommand": "npm run prepare:lfs && npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
@@ -41,6 +41,12 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"externalBin": [
|
||||
"binaries/git-lfs"
|
||||
],
|
||||
"resources": [
|
||||
"binaries/git-lfs-LICENSE.txt"
|
||||
],
|
||||
"targets": [
|
||||
"nsis"
|
||||
],
|
||||
|
||||
+349
-8
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount, tick } from "svelte";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
||||
@@ -35,6 +36,7 @@
|
||||
import UpdateToast from "./lib/components/UpdateToast.svelte";
|
||||
|
||||
import {
|
||||
addToGitignore,
|
||||
amendCommit,
|
||||
addRemote,
|
||||
addWorktree,
|
||||
@@ -68,7 +70,9 @@
|
||||
fetchRemote,
|
||||
getCommitNote,
|
||||
getFileBlame,
|
||||
getGitLfsStatus,
|
||||
getStatus,
|
||||
installGitLfs,
|
||||
lastCommitMessage,
|
||||
listBranches,
|
||||
listRemotes,
|
||||
@@ -89,7 +93,9 @@
|
||||
lockWorktree,
|
||||
moveWorktree,
|
||||
pruneWorktrees,
|
||||
pruneGitLfsObjects,
|
||||
pull,
|
||||
pullGitLfsObjects,
|
||||
push,
|
||||
pushCommitNotes,
|
||||
pushTag,
|
||||
@@ -124,12 +130,15 @@
|
||||
startInteractiveRebase,
|
||||
setSyncBadge,
|
||||
stageFiles,
|
||||
trackGitLfsPattern,
|
||||
stashApply,
|
||||
stashDrop,
|
||||
stashPop,
|
||||
stashPush,
|
||||
undoLastCommit,
|
||||
unlockWorktree,
|
||||
untrackGitLfsPattern,
|
||||
untrackPaths,
|
||||
unstageFiles,
|
||||
} from "./lib/git";
|
||||
|
||||
@@ -137,10 +146,12 @@
|
||||
AiReviewResult,
|
||||
AiCommitPlan,
|
||||
AiSettings,
|
||||
AppAppearance,
|
||||
AppLanguage,
|
||||
AppTheme,
|
||||
AnalyticsSettings,
|
||||
CommitAiPhase,
|
||||
CustomThemeColors,
|
||||
ConflictFile,
|
||||
DetectedExternalTool,
|
||||
ExplorerNode,
|
||||
@@ -154,6 +165,8 @@
|
||||
GitCommitComparison,
|
||||
GitDiffFile,
|
||||
GitFileStatus,
|
||||
GitIgnoreKind,
|
||||
GitLfsStatus,
|
||||
GitRepositoryFile,
|
||||
GitRemote,
|
||||
PullStrategy,
|
||||
@@ -242,6 +255,8 @@
|
||||
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
|
||||
const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1";
|
||||
const APP_THEME_KEY = "gitlite.theme.v1";
|
||||
const APP_APPEARANCE_KEY = "gitlite.appearance.v1";
|
||||
const CUSTOM_THEME_KEY = "gitlite.customTheme.v1";
|
||||
const APP_LANGUAGE_KEY = "gitlite.language.v1";
|
||||
const EXTERNAL_TOOLS_SETTINGS_KEY = "gitlite.externalTools.v1";
|
||||
const AUTO_REFRESH_ENABLED_KEY = "gitlite.autoRefreshEnabled.v1";
|
||||
@@ -275,6 +290,11 @@
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let repoPath = "";
|
||||
let startupReady = false;
|
||||
let pendingStartupRepoPath = "";
|
||||
let pendingStartupCloneRequest: CloneRequest | null = null;
|
||||
let startupRepositoryRetryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let unlistenStartupRepository: (() => void) | undefined;
|
||||
let activeRepoPath = "";
|
||||
let activeView: AppView = "management";
|
||||
let repoTabs: RepoTab[] = [];
|
||||
@@ -348,6 +368,8 @@
|
||||
let analyticsNoticeOpen = false;
|
||||
let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings();
|
||||
let appTheme: AppTheme = loadThemePreference();
|
||||
let appAppearance: AppAppearance = loadAppearancePreference();
|
||||
let customTheme: CustomThemeColors = loadCustomTheme();
|
||||
let appLanguage: AppLanguage = loadLanguagePreference();
|
||||
let externalToolsSettings: ExternalToolsSettings = loadExternalToolsSettings();
|
||||
let externalToolsConfigured = hasStoredExternalToolsSettings();
|
||||
@@ -371,6 +393,10 @@
|
||||
let worktrees: GitWorktree[] = [];
|
||||
let worktreesLoading = false;
|
||||
let worktreeError = "";
|
||||
let gitLfsDialogOpen = false;
|
||||
let gitLfsStatus: GitLfsStatus | null = null;
|
||||
let gitLfsLoading = false;
|
||||
let gitLfsError = "";
|
||||
let compareSelectOpen = false;
|
||||
let compareDialogOpen = false;
|
||||
let interactiveRebaseOpen = false;
|
||||
@@ -535,6 +561,7 @@
|
||||
$: fileManagerToolName = externalToolDisplayName("fileManager", externalToolsSettings.fileManager, detectedExternalTools);
|
||||
|
||||
$: applyThemePreference(appTheme);
|
||||
$: applyAppearancePreference(appAppearance, customTheme);
|
||||
$: applyLanguagePreference(appLanguage);
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
@@ -543,6 +570,14 @@
|
||||
themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
|
||||
themeMediaQuery.addEventListener("change", handleSystemThemeChange);
|
||||
void runStartupSequence();
|
||||
void listen("open-startup-repository", () => {
|
||||
void receiveStartupRepository();
|
||||
}).then((unlisten) => {
|
||||
if (appShuttingDown) unlisten();
|
||||
else unlistenStartupRepository = unlisten;
|
||||
}).catch(() => {
|
||||
// Browser preview has no Tauri event system.
|
||||
});
|
||||
void refreshDetectedExternalTools();
|
||||
void getVersion().then((version) => { appVersion = version; }).catch(() => { appVersion = ""; });
|
||||
window.addEventListener("beforeunload", handleAppShutdown);
|
||||
@@ -565,7 +600,9 @@
|
||||
window.removeEventListener("beforeunload", handleAppShutdown);
|
||||
window.removeEventListener("pagehide", handleAppShutdown);
|
||||
unlistenCloseRequested?.();
|
||||
unlistenStartupRepository?.();
|
||||
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
|
||||
if (startupRepositoryRetryTimer) clearTimeout(startupRepositoryRetryTimer);
|
||||
Object.values(errorAutoHideStates).forEach((state) => {
|
||||
if (state?.timer) clearTimeout(state.timer);
|
||||
});
|
||||
@@ -642,6 +679,8 @@
|
||||
if (remainingSplashTime > 0) await wait(remainingSplashTime);
|
||||
|
||||
await closeStartupSplashscreen();
|
||||
startupReady = true;
|
||||
await receiveStartupRepository();
|
||||
startBackgroundTimers();
|
||||
// Remote access can take seconds (offline networks, SSH negotiation,
|
||||
// credential helpers). It must never hold the startup screen hostage.
|
||||
@@ -650,6 +689,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function receiveStartupRepository() {
|
||||
try {
|
||||
const [path, cloneRequest] = await Promise.all([
|
||||
tracedInvoke<string | null>("take_startup_repository"),
|
||||
tracedInvoke<CloneRequest | null>("take_startup_clone"),
|
||||
]);
|
||||
if (cloneRequest) {
|
||||
pendingStartupCloneRequest = cloneRequest;
|
||||
pendingStartupRepoPath = "";
|
||||
} else if (path) {
|
||||
pendingStartupRepoPath = path;
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!startupReady || (!pendingStartupCloneRequest && !pendingStartupRepoPath)) return;
|
||||
if (isBusy) {
|
||||
if (!startupRepositoryRetryTimer) {
|
||||
startupRepositoryRetryTimer = setTimeout(() => {
|
||||
startupRepositoryRetryTimer = undefined;
|
||||
void receiveStartupRepository();
|
||||
}, 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pendingStartupCloneRequest) {
|
||||
const request = pendingStartupCloneRequest;
|
||||
pendingStartupCloneRequest = null;
|
||||
await cloneRepo(request.remoteUrl, request.parentPath, request.directoryName);
|
||||
return;
|
||||
}
|
||||
const path = pendingStartupRepoPath;
|
||||
pendingStartupRepoPath = "";
|
||||
await openRepo(path);
|
||||
}
|
||||
|
||||
async function fetchOpenRepositoriesDuringStartup() {
|
||||
const paths = uniqueRepoPaths(repoTabs.map((tab) => tab.path));
|
||||
if (paths.length === 0 || backgroundFetchInFlight) return;
|
||||
@@ -916,7 +992,7 @@
|
||||
}
|
||||
|
||||
async function autoRefreshTick() {
|
||||
if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || newBranchCommit || commitNoteTarget || globalSearchOpen || helpOpen) return;
|
||||
if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || gitLfsDialogOpen || newBranchCommit || commitNoteTarget || globalSearchOpen || helpOpen) return;
|
||||
const path = activeRepoPath;
|
||||
autoRefreshInFlight = true;
|
||||
try {
|
||||
@@ -1050,6 +1126,102 @@
|
||||
}
|
||||
}
|
||||
|
||||
function defaultCustomTheme(): CustomThemeColors {
|
||||
return {
|
||||
background: "#cfd5dc",
|
||||
surface: "#e2e6ea",
|
||||
accent: "#2eb5d1",
|
||||
text: "#222328",
|
||||
};
|
||||
}
|
||||
|
||||
function isHexColor(value: unknown): value is string {
|
||||
return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value);
|
||||
}
|
||||
|
||||
function loadAppearancePreference(): AppAppearance {
|
||||
try {
|
||||
const stored = localStorage.getItem(APP_APPEARANCE_KEY);
|
||||
if (stored === "modern" || stored === "classic" || stored === "custom") return stored;
|
||||
} catch {
|
||||
// Local storage is optional; the current design remains the default.
|
||||
}
|
||||
return "modern";
|
||||
}
|
||||
|
||||
function loadCustomTheme(): CustomThemeColors {
|
||||
const fallback = defaultCustomTheme();
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(CUSTOM_THEME_KEY) ?? "null") as Partial<CustomThemeColors> | null;
|
||||
if (!stored) return fallback;
|
||||
return {
|
||||
background: isHexColor(stored.background) ? stored.background : fallback.background,
|
||||
surface: isHexColor(stored.surface) ? stored.surface : fallback.surface,
|
||||
accent: isHexColor(stored.accent) ? stored.accent : fallback.accent,
|
||||
text: isHexColor(stored.text) ? stored.text : fallback.text,
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function persistAppearancePreference(next: AppAppearance, colors: CustomThemeColors) {
|
||||
try {
|
||||
localStorage.setItem(APP_APPEARANCE_KEY, next);
|
||||
localStorage.setItem(CUSTOM_THEME_KEY, JSON.stringify(colors));
|
||||
} catch {
|
||||
// Ignore storage quota/private-mode errors.
|
||||
}
|
||||
}
|
||||
|
||||
function applyAppearancePreference(next: AppAppearance, colors: CustomThemeColors) {
|
||||
const root = document.documentElement;
|
||||
root.dataset.appearance = next;
|
||||
const customProperties = [
|
||||
"--app-bg", "--app-button-bg", "--app-input-bg", "--app-dialog-bg", "--app-dialog-chrome",
|
||||
"--app-dialog-backdrop", "--app-dialog-shadow", "--app-panel-shadow", "--app-settings-row-bg",
|
||||
"--color-surface", "--color-surface-alt", "--color-surface-dim",
|
||||
"--color-surface-hover", "--color-surface-raised", "--color-surface-solid", "--color-border",
|
||||
"--color-border-subtle", "--color-border-input", "--color-primary", "--color-primary-dark",
|
||||
"--color-accent", "--color-ink", "--color-ink-muted", "--color-ink-faint", "--color-ink-dim",
|
||||
"--color-bar-text", "--color-bar-muted",
|
||||
];
|
||||
customProperties.forEach((property) => root.style.removeProperty(property));
|
||||
if (next !== "custom") return;
|
||||
|
||||
const { background, surface, accent, text } = colors;
|
||||
const values: Record<string, string> = {
|
||||
"--app-bg": background,
|
||||
"--app-button-bg": surface,
|
||||
"--app-input-bg": `color-mix(in srgb, ${surface} 88%, white)`,
|
||||
"--app-dialog-bg": surface,
|
||||
"--app-dialog-chrome": `color-mix(in srgb, ${surface} 90%, ${background})`,
|
||||
"--app-dialog-backdrop": `color-mix(in srgb, ${background} 72%, transparent)`,
|
||||
"--app-dialog-shadow": `0 24px 68px color-mix(in srgb, ${text} 24%, transparent), 0 2px 12px color-mix(in srgb, ${text} 12%, transparent)`,
|
||||
"--app-panel-shadow": `0 18px 48px color-mix(in srgb, ${text} 18%, transparent), inset 0 1px 0 color-mix(in srgb, ${surface} 88%, white)`,
|
||||
"--app-settings-row-bg": `color-mix(in srgb, ${surface} 92%, ${background})`,
|
||||
"--color-surface": surface,
|
||||
"--color-surface-alt": `color-mix(in srgb, ${surface} 82%, ${background})`,
|
||||
"--color-surface-dim": `color-mix(in srgb, ${surface} 88%, ${background})`,
|
||||
"--color-surface-hover": `color-mix(in srgb, ${surface} 88%, ${text})`,
|
||||
"--color-surface-raised": `color-mix(in srgb, ${surface} 92%, white)`,
|
||||
"--color-surface-solid": surface,
|
||||
"--color-border": `color-mix(in srgb, ${text} 32%, ${surface})`,
|
||||
"--color-border-subtle": `color-mix(in srgb, ${text} 18%, ${surface})`,
|
||||
"--color-border-input": `color-mix(in srgb, ${text} 38%, ${surface})`,
|
||||
"--color-primary": accent,
|
||||
"--color-primary-dark": `color-mix(in srgb, ${accent} 82%, black)`,
|
||||
"--color-accent": accent,
|
||||
"--color-ink": text,
|
||||
"--color-ink-muted": `color-mix(in srgb, ${text} 76%, ${surface})`,
|
||||
"--color-ink-faint": `color-mix(in srgb, ${text} 58%, ${surface})`,
|
||||
"--color-ink-dim": `color-mix(in srgb, ${text} 68%, ${surface})`,
|
||||
"--color-bar-text": text,
|
||||
"--color-bar-muted": `color-mix(in srgb, ${text} 62%, ${surface})`,
|
||||
};
|
||||
Object.entries(values).forEach(([property, value]) => root.style.setProperty(property, value));
|
||||
}
|
||||
|
||||
function loadLanguagePreference(): AppLanguage {
|
||||
try {
|
||||
const stored = localStorage.getItem(APP_LANGUAGE_KEY);
|
||||
@@ -1088,15 +1260,18 @@
|
||||
if (appTheme === "system") applyThemePreference(appTheme);
|
||||
}
|
||||
|
||||
function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme, nextLanguage: AppLanguage, nextAutoRefresh: boolean, nextExternalTools: ExternalToolsSettings) {
|
||||
function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme, nextAppearance: AppAppearance, nextCustomTheme: CustomThemeColors, nextLanguage: AppLanguage, nextAutoRefresh: boolean, nextExternalTools: ExternalToolsSettings) {
|
||||
const autoRefreshWasEnabled = autoRefreshEnabled;
|
||||
analyticsSettings = next;
|
||||
appTheme = nextTheme;
|
||||
appAppearance = nextAppearance;
|
||||
customTheme = nextCustomTheme;
|
||||
appLanguage = nextLanguage;
|
||||
autoRefreshEnabled = nextAutoRefresh;
|
||||
externalToolsSettings = nextExternalTools;
|
||||
persistAnalyticsSettings(next);
|
||||
persistThemePreference(nextTheme);
|
||||
persistAppearancePreference(nextAppearance, nextCustomTheme);
|
||||
persistLanguagePreference(nextLanguage);
|
||||
persistStoredBoolean(AUTO_REFRESH_ENABLED_KEY, nextAutoRefresh);
|
||||
persistExternalToolsSettings(nextExternalTools);
|
||||
@@ -1104,7 +1279,7 @@
|
||||
setTelemetryEnabled(next.enabled);
|
||||
appSettingsOpen = false;
|
||||
if (nextAutoRefresh && !autoRefreshWasEnabled) void autoRefreshTick();
|
||||
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1, theme: nextTheme, language: nextLanguage, auto_refresh: nextAutoRefresh ? 1 : 0 });
|
||||
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1, theme: nextTheme, appearance: nextAppearance, language: nextLanguage, auto_refresh: nextAutoRefresh ? 1 : 0 });
|
||||
}
|
||||
|
||||
function updateCommitMessage(message: string) {
|
||||
@@ -2009,7 +2184,6 @@
|
||||
function isNonFastForwardPushError(message: string): boolean {
|
||||
const value = message.toLowerCase();
|
||||
return value.includes("non-fast-forward")
|
||||
|| value.includes("failed to push some refs")
|
||||
|| value.includes("tip of your current branch is behind")
|
||||
|| value.includes("fetch first");
|
||||
}
|
||||
@@ -2355,6 +2529,7 @@
|
||||
activeView = "repository";
|
||||
cloneDialogOpen = false;
|
||||
pendingClone = null;
|
||||
errorMessage = bundle.warning ?? "";
|
||||
if (credDialogAction === "clone") {
|
||||
credDialogOpen = false;
|
||||
credDialogAction = null;
|
||||
@@ -2365,6 +2540,7 @@
|
||||
trackEvent("repository_cloned", {
|
||||
changed_files: bundle.status.files.length,
|
||||
has_upstream: bundle.status.upstream ? 1 : 0,
|
||||
lfs_warning: bundle.warning ? 1 : 0,
|
||||
});
|
||||
} catch (error) {
|
||||
const rawMessage = errorToMessage(error);
|
||||
@@ -2915,6 +3091,114 @@
|
||||
worktreeError = "";
|
||||
}
|
||||
|
||||
async function openGitLfsDialog() {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
gitLfsDialogOpen = true;
|
||||
gitLfsStatus = null;
|
||||
gitLfsError = "";
|
||||
await refreshGitLfsStatus();
|
||||
const inspectedStatus = gitLfsStatus as GitLfsStatus | null;
|
||||
trackEvent("git_lfs_dialog_opened", {
|
||||
available: inspectedStatus?.available ? 1 : 0,
|
||||
repository_uses_lfs: inspectedStatus?.repository_uses_lfs ? 1 : 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshGitLfsStatus() {
|
||||
if (!activeRepoPath || gitLfsLoading) return;
|
||||
gitLfsLoading = true;
|
||||
gitLfsError = "";
|
||||
try {
|
||||
gitLfsStatus = await getGitLfsStatus(activeRepoPath);
|
||||
} catch (error) {
|
||||
gitLfsError = errorToMessage(error);
|
||||
} finally {
|
||||
gitLfsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runGitLfsOperation(
|
||||
label: string,
|
||||
task: () => Promise<GitLfsStatus>,
|
||||
eventName: string,
|
||||
): Promise<boolean> {
|
||||
if (!activeRepoPath || isBusy) return false;
|
||||
const repository = activeRepoPath;
|
||||
operation = label;
|
||||
gitLfsError = "";
|
||||
try {
|
||||
gitLfsStatus = await task();
|
||||
applyStatus(await getStatus(repository));
|
||||
await refreshExplorerFiles(repository);
|
||||
trackEvent(eventName, {
|
||||
patterns: gitLfsStatus.patterns.length,
|
||||
files: gitLfsStatus.files.length,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
gitLfsError = errorToMessage(error);
|
||||
return false;
|
||||
} finally {
|
||||
operation = "";
|
||||
}
|
||||
}
|
||||
|
||||
function activateGitLfs() {
|
||||
return runGitLfsOperation(
|
||||
"Activating Git LFS",
|
||||
() => installGitLfs(activeRepoPath),
|
||||
"git_lfs_activated",
|
||||
);
|
||||
}
|
||||
|
||||
function addGitLfsPattern(pattern: string, lockable: boolean): Promise<boolean> {
|
||||
return runGitLfsOperation(
|
||||
`Tracking ${pattern} with Git LFS`,
|
||||
() => trackGitLfsPattern(activeRepoPath, pattern, lockable),
|
||||
"git_lfs_pattern_added",
|
||||
);
|
||||
}
|
||||
|
||||
async function removeGitLfsPattern(pattern: string) {
|
||||
await runGitLfsOperation(
|
||||
`Removing Git LFS pattern ${pattern}`,
|
||||
() => untrackGitLfsPattern(activeRepoPath, pattern),
|
||||
"git_lfs_pattern_removed",
|
||||
);
|
||||
}
|
||||
|
||||
async function pullGitLfsFiles() {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
let credential: StoredCredential | null = null;
|
||||
const key = await currentCredKey("pull");
|
||||
if (key) credential = await credLoad(key);
|
||||
await runGitLfsOperation(
|
||||
"Downloading Git LFS objects",
|
||||
() => pullGitLfsObjects(
|
||||
activeRepoPath,
|
||||
selectedRemote || undefined,
|
||||
credential?.username,
|
||||
credential?.password,
|
||||
),
|
||||
"git_lfs_objects_pulled",
|
||||
);
|
||||
}
|
||||
|
||||
async function pruneGitLfsCache() {
|
||||
await runGitLfsOperation(
|
||||
"Pruning Git LFS cache",
|
||||
() => pruneGitLfsObjects(activeRepoPath),
|
||||
"git_lfs_cache_pruned",
|
||||
);
|
||||
}
|
||||
|
||||
function closeGitLfsDialog() {
|
||||
if (isBusy) return;
|
||||
gitLfsDialogOpen = false;
|
||||
gitLfsStatus = null;
|
||||
gitLfsError = "";
|
||||
}
|
||||
|
||||
function openNewBranchDialog(commit: GitCommit) {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
newBranchCommit = commit;
|
||||
@@ -3693,11 +3977,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStash(message: string, includeUntracked: boolean) {
|
||||
if (!activeRepoPath || changedFiles.length === 0) return;
|
||||
const stashedFiles = changedFiles.length;
|
||||
async function saveStash(message: string, includeUntracked: boolean, files?: GitFileStatus[]) {
|
||||
const targets = files ?? changedFiles;
|
||||
if (!activeRepoPath || targets.length === 0) return;
|
||||
const paths = files
|
||||
? [...new Set(files.flatMap((file) => file.old_path ? [file.old_path, file.path] : [file.path]))]
|
||||
: undefined;
|
||||
const stashedFiles = targets.length;
|
||||
await runOperation("Stashing changes", async () => {
|
||||
applyStatus(await stashPush(activeRepoPath, message, includeUntracked));
|
||||
applyStatus(await stashPush(activeRepoPath, message, includeUntracked, paths));
|
||||
await refreshRepositoryViews(activeRepoPath, {
|
||||
branches: false,
|
||||
stashes: true,
|
||||
@@ -3706,10 +3994,16 @@
|
||||
trackEvent("stash_saved", {
|
||||
include_untracked: includeUntracked ? 1 : 0,
|
||||
changed_files: stashedFiles,
|
||||
scoped: files ? 1 : 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function stashStatusFiles(files: GitFileStatus[], label: string) {
|
||||
const suffix = files.length === 1 ? files[0].path : `${label} (${files.length} files)`;
|
||||
await saveStash(`Gitty: ${suffix}`, true, files);
|
||||
}
|
||||
|
||||
async function applyStashEntry(stash: GitStash) {
|
||||
if (!activeRepoPath) return;
|
||||
await runOperation(`Applying ${stash.selector}`, async () => {
|
||||
@@ -3778,6 +4072,25 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function ignoreStatusTarget(target: string, kind: GitIgnoreKind) {
|
||||
if (!activeRepoPath || !target) return;
|
||||
const description = kind === "folder" ? "folder" : kind === "extension" ? "file extension" : "file";
|
||||
await runOperation(`Ignoring ${description}`, async () => {
|
||||
applyStatus(await addToGitignore(activeRepoPath, target, kind));
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
trackEvent("gitignore_rule_added", { kind });
|
||||
});
|
||||
}
|
||||
|
||||
async function stopTrackingTarget(target: string, kind: "file" | "folder") {
|
||||
if (!activeRepoPath || !target) return;
|
||||
await runOperation(`Stopping tracking for ${kind}`, async () => {
|
||||
applyStatus(await untrackPaths(activeRepoPath, [target]));
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
trackEvent("git_paths_untracked", { kind });
|
||||
});
|
||||
}
|
||||
|
||||
function discardFiles(files: GitFileStatus[], staged: boolean) {
|
||||
if (!activeRepoPath || isBusy || files.length === 0) return;
|
||||
pendingDiscard = { kind: "file", files, staged };
|
||||
@@ -4665,6 +4978,7 @@
|
||||
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
|
||||
else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog();
|
||||
else if (event.key === "Escape" && worktreeDialogOpen) closeWorktreeDialog();
|
||||
else if (event.key === "Escape" && gitLfsDialogOpen) closeGitLfsDialog();
|
||||
else if (event.key === "Escape" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false;
|
||||
else if (event.key === "Escape" && reflogOpen && !isBusy) reflogOpen = false;
|
||||
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
|
||||
@@ -4738,6 +5052,7 @@
|
||||
onFetchPrune={fetchPruneRepo}
|
||||
onForcePush={forcePushRepo}
|
||||
onSyncOptions={openSyncOptions}
|
||||
onOpenLfs={openGitLfsDialog}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -5138,6 +5453,8 @@
|
||||
onExternalDiff={compareExplorerFileExternally}
|
||||
onFileHistory={openFileHistoryDialog}
|
||||
onBlame={openBlame}
|
||||
onIgnore={ignoreStatusTarget}
|
||||
onStopTracking={stopTrackingTarget}
|
||||
collapsed={explorerPanelCollapsed}
|
||||
onToggleCollapsed={toggleExplorerPanelCollapsed}
|
||||
/>
|
||||
@@ -5206,6 +5523,9 @@
|
||||
onUnstage={unstageFile}
|
||||
onDiscard={discardFiles}
|
||||
onDiscardMany={discardChanges}
|
||||
onStash={stashStatusFiles}
|
||||
onIgnore={ignoreStatusTarget}
|
||||
onStopTracking={stopTrackingTarget}
|
||||
onPatch={openPreferredFileDiff}
|
||||
onStageAll={stageAllFiles}
|
||||
onUnstageAll={unstageAllFiles}
|
||||
@@ -5385,6 +5705,8 @@
|
||||
<AppSettingsDialog
|
||||
analytics={analyticsSettings}
|
||||
theme={appTheme}
|
||||
appearance={appAppearance}
|
||||
customTheme={customTheme}
|
||||
language={appLanguage}
|
||||
autoRefresh={autoRefreshEnabled}
|
||||
externalTools={externalToolsSettings}
|
||||
@@ -5525,6 +5847,25 @@
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
{#if gitLfsDialogOpen}
|
||||
{#await import("./lib/components/GitLfsDialog.svelte") then module}
|
||||
<module.default
|
||||
status={gitLfsStatus}
|
||||
language={appLanguage}
|
||||
isLoading={gitLfsLoading}
|
||||
{isBusy}
|
||||
error={gitLfsError}
|
||||
onRefresh={refreshGitLfsStatus}
|
||||
onInstall={activateGitLfs}
|
||||
onTrack={addGitLfsPattern}
|
||||
onUntrack={removeGitLfsPattern}
|
||||
onPull={pullGitLfsFiles}
|
||||
onPrune={pruneGitLfsCache}
|
||||
onClose={closeGitLfsDialog}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
<!-- Create a branch from a specific commit in the history -->
|
||||
{#if newBranchCommit}
|
||||
<NewBranchDialog
|
||||
|
||||
+1332
-72
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Box,
|
||||
ChevronDown,
|
||||
Code2,
|
||||
CloudDownload,
|
||||
@@ -41,6 +42,7 @@
|
||||
export let onFetchPrune: () => void = () => {};
|
||||
export let onForcePush: () => void = () => {};
|
||||
export let onSyncOptions: () => void = () => {};
|
||||
export let onOpenLfs: () => void = () => {};
|
||||
|
||||
let historyOpen = false;
|
||||
let syncOpen = false;
|
||||
@@ -142,6 +144,7 @@
|
||||
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onFetchPrune(); }}><CloudDownload size={15} /><span><strong>Fetch + Prune</strong><small>{isGerman ? "Veraltete Remote-Branches entfernen" : "Remove stale remote branches"}</small></span></button>
|
||||
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onForcePush(); }}><Upload size={15} /><span><strong>Force with lease</strong><small>{isGerman ? "Sicheres Pushen nach Rebase" : "Safe push after rebase"}</small></span></button>
|
||||
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onSyncOptions(); }}><Settings2 size={15} /><span><strong>{isGerman ? "Remotes & Strategien" : "Remotes & strategies"}</strong><small>{isGerman ? "Upstream, Pull und Remote verwalten" : "Manage upstream, pull and remotes"}</small></span></button>
|
||||
<button type="button" role="menuitem" onclick={() => { syncOpen = false; onOpenLfs(); }}><Box size={15} /><span><strong>Git LFS</strong><small>{isGerman ? "Große Dateien und LFS-Installation verwalten" : "Manage large files and LFS installation"}</small></span></button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -31,8 +31,10 @@
|
||||
} from "../externalTools";
|
||||
import type {
|
||||
AnalyticsSettings,
|
||||
AppAppearance,
|
||||
AppLanguage,
|
||||
AppTheme,
|
||||
CustomThemeColors,
|
||||
DetectedExternalTool,
|
||||
ExternalToolsSettings,
|
||||
ToolOpenMode,
|
||||
@@ -44,6 +46,8 @@
|
||||
interface Props {
|
||||
analytics: AnalyticsSettings;
|
||||
theme: AppTheme;
|
||||
appearance: AppAppearance;
|
||||
customTheme: CustomThemeColors;
|
||||
language: AppLanguage;
|
||||
autoRefresh: boolean;
|
||||
externalTools: ExternalToolsSettings;
|
||||
@@ -51,13 +55,15 @@
|
||||
detectionPending: boolean;
|
||||
detectionUnavailable: boolean;
|
||||
onRefreshDetectedTools: () => void | Promise<void>;
|
||||
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage, autoRefresh: boolean, externalTools: ExternalToolsSettings) => void;
|
||||
onSave: (settings: AnalyticsSettings, theme: AppTheme, appearance: AppAppearance, customTheme: CustomThemeColors, language: AppLanguage, autoRefresh: boolean, externalTools: ExternalToolsSettings) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
analytics,
|
||||
theme = "system",
|
||||
appearance = "modern",
|
||||
customTheme = { background: "#cfd5dc", surface: "#e2e6ea", accent: "#2eb5d1", text: "#222328" },
|
||||
language = "en",
|
||||
autoRefresh = true,
|
||||
externalTools,
|
||||
@@ -76,6 +82,8 @@
|
||||
let advancedOpen = $state(false);
|
||||
let analyticsEnabled = $state(true);
|
||||
let selectedTheme = $state<AppTheme>("system");
|
||||
let selectedAppearance = $state<AppAppearance>("modern");
|
||||
let customColors = $state<CustomThemeColors>({ background: "#cfd5dc", surface: "#e2e6ea", accent: "#2eb5d1", text: "#222328" });
|
||||
let selectedLanguage = $state<AppLanguage>("en");
|
||||
let autoRefreshEnabled = $state(true);
|
||||
let tools = $state<ExternalToolsSettings>(defaultExternalToolsSettings());
|
||||
@@ -84,6 +92,8 @@
|
||||
$effect(() => {
|
||||
analyticsEnabled = analytics.enabled;
|
||||
selectedTheme = theme;
|
||||
selectedAppearance = appearance;
|
||||
customColors = structuredClone(customTheme);
|
||||
selectedLanguage = language;
|
||||
autoRefreshEnabled = autoRefresh;
|
||||
tools = structuredClone(externalTools);
|
||||
@@ -94,7 +104,46 @@
|
||||
...analytics,
|
||||
enabled: analyticsEnabled,
|
||||
noticeSeen: true,
|
||||
}, selectedTheme, selectedLanguage, autoRefreshEnabled, $state.snapshot(tools));
|
||||
}, selectedTheme, selectedAppearance, $state.snapshot(customColors), selectedLanguage, autoRefreshEnabled, $state.snapshot(tools));
|
||||
}
|
||||
|
||||
function resetCustomColors() {
|
||||
customColors = selectedTheme === "dark"
|
||||
? { background: "#222328", surface: "#2b2e34", accent: "#2eb5d1", text: "#f0f1f2" }
|
||||
: { background: "#cfd5dc", surface: "#e2e6ea", accent: "#2eb5d1", text: "#222328" };
|
||||
}
|
||||
|
||||
function cssColorToHex(value: string, fallback: string): string {
|
||||
const color = value.trim();
|
||||
if (/^#[0-9a-f]{6}$/i.test(color)) return color.toLowerCase();
|
||||
if (!color || typeof document === "undefined") return fallback;
|
||||
const probe = document.createElement("span");
|
||||
probe.style.color = color;
|
||||
if (!probe.style.color) return fallback;
|
||||
probe.style.display = "none";
|
||||
document.body.appendChild(probe);
|
||||
const match = getComputedStyle(probe).color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
|
||||
probe.remove();
|
||||
if (!match) return fallback;
|
||||
return `#${match.slice(1, 4).map((part) => Number(part).toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
function currentThemeColors(): CustomThemeColors {
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const fallback = selectedTheme === "dark"
|
||||
? { background: "#222328", surface: "#2b2e34", accent: "#2eb5d1", text: "#f0f1f2" }
|
||||
: { background: "#cfd5dc", surface: "#e2e6ea", accent: "#2eb5d1", text: "#222328" };
|
||||
return {
|
||||
background: cssColorToHex(styles.getPropertyValue("--app-bg"), fallback.background),
|
||||
surface: cssColorToHex(styles.getPropertyValue("--color-surface"), fallback.surface),
|
||||
accent: cssColorToHex(styles.getPropertyValue("--color-accent"), fallback.accent),
|
||||
text: cssColorToHex(styles.getPropertyValue("--color-ink"), fallback.text),
|
||||
};
|
||||
}
|
||||
|
||||
function selectAppearance(next: AppAppearance) {
|
||||
if (next === "custom" && selectedAppearance !== "custom") customColors = currentThemeColors();
|
||||
selectedAppearance = next;
|
||||
}
|
||||
|
||||
function toolLabel(kind: ExternalToolKind): string {
|
||||
@@ -292,6 +341,40 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="general-setting-panel">
|
||||
<header><SlidersHorizontal size={16} /><div><h4>{isGerman ? "Darstellungsstil" : "Design style"}</h4><p>{isGerman ? "Aktuell, klassisch oder selbst gestaltet." : "Current, classic, or designed by you."}</p></div></header>
|
||||
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Darstellungsstil" : "Design style"}>
|
||||
<label class:active={selectedAppearance === "modern"}><input type="radio" name="appearance" value="modern" checked={selectedAppearance === "modern"} onchange={() => selectAppearance("modern")} /><span>{isGerman ? "Aktuell" : "Modern"}</span></label>
|
||||
<label class:active={selectedAppearance === "classic"}><input type="radio" name="appearance" value="classic" checked={selectedAppearance === "classic"} onchange={() => selectAppearance("classic")} /><span>{isGerman ? "Klassisch" : "Classic"}</span></label>
|
||||
<label class:active={selectedAppearance === "custom"}><input type="radio" name="appearance" value="custom" checked={selectedAppearance === "custom"} onchange={() => selectAppearance("custom")} /><span>{isGerman ? "Eigene" : "Custom"}</span></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if selectedAppearance === "custom"}
|
||||
<section class="general-setting-panel general-setting-wide custom-theme-panel">
|
||||
<header>
|
||||
<Palette size={16} />
|
||||
<div><h4>{isGerman ? "Theme-Generator" : "Theme generator"}</h4><p>{isGerman ? "Erstelle dein eigenes Farbprofil." : "Create your own color profile."}</p></div>
|
||||
<button class="theme-reset-button" type="button" onclick={resetCustomColors}><RotateCw size={13} />{isGerman ? "Zurücksetzen" : "Reset"}</button>
|
||||
</header>
|
||||
<div
|
||||
class="theme-preview"
|
||||
style={`--preview-bg:${customColors.background};--preview-surface:${customColors.surface};--preview-accent:${customColors.accent};--preview-text:${customColors.text};`}
|
||||
aria-label={isGerman ? "Vorschau des eigenen Themes" : "Custom theme preview"}
|
||||
>
|
||||
<span class="theme-preview-sidebar"></span>
|
||||
<span class="theme-preview-content"><i></i><b></b><em></em></span>
|
||||
</div>
|
||||
<div class="theme-color-grid">
|
||||
<label><span>{isGerman ? "Hintergrund" : "Background"}</span><input type="color" bind:value={customColors.background} aria-label={isGerman ? "Hintergrundfarbe" : "Background color"} /><code>{customColors.background}</code></label>
|
||||
<label><span>{isGerman ? "Fläche" : "Surface"}</span><input type="color" bind:value={customColors.surface} aria-label={isGerman ? "Flächenfarbe" : "Surface color"} /><code>{customColors.surface}</code></label>
|
||||
<label><span>{isGerman ? "Akzent" : "Accent"}</span><input type="color" bind:value={customColors.accent} aria-label={isGerman ? "Akzentfarbe" : "Accent color"} /><code>{customColors.accent}</code></label>
|
||||
<label><span>{isGerman ? "Schrift" : "Text"}</span><input type="color" bind:value={customColors.text} aria-label={isGerman ? "Schriftfarbe" : "Text color"} /><code>{customColors.text}</code></label>
|
||||
</div>
|
||||
<p class="theme-generator-note">{isGerman ? "Die Farben werden beim Speichern auf die gesamte Oberfläche angewendet." : "The colors are applied across the interface when you save."}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="general-setting-panel">
|
||||
<header><Languages size={16} /><div><h4>{isGerman ? "Sprache" : "Language"}</h4><p>{isGerman ? "Sprache der Oberfläche." : "Language used by the interface."}</p></div></header>
|
||||
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
|
||||
@@ -534,6 +617,22 @@
|
||||
.settings-segmented label { display: flex; align-items: center; justify-content: center; min-height: 31px; border: 1px solid transparent; border-radius: 6px; color: var(--color-ink-dim); font-size: 10.5px; font-weight: 750; }
|
||||
.settings-segmented label.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-hover); }
|
||||
.settings-segmented input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.custom-theme-panel > header { align-items: center; }
|
||||
.custom-theme-panel > header > div { min-width: 0; }
|
||||
.theme-reset-button { display: inline-flex; align-items: center; gap: 5px; min-height: 27px; margin-left: auto; padding: 0 8px; border: 1px solid var(--color-border); color: var(--color-ink-muted); background: var(--color-surface-raised); font-size: 9.5px; font-weight: 750; }
|
||||
.theme-reset-button:hover:not(:disabled) { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-hover); }
|
||||
.theme-preview { display: grid; grid-template-columns: 28% 1fr; min-height: 78px; overflow: hidden; border: 1px solid color-mix(in srgb, var(--preview-text) 30%, var(--preview-surface)); background: var(--preview-bg); }
|
||||
.theme-preview-sidebar { border-right: 1px solid color-mix(in srgb, var(--preview-text) 24%, var(--preview-surface)); background: color-mix(in srgb, var(--preview-surface) 86%, var(--preview-bg)); }
|
||||
.theme-preview-content { display: grid; grid-template-columns: 1fr auto; align-content: start; gap: 8px; margin: 10px; padding: 10px; border: 1px solid color-mix(in srgb, var(--preview-text) 22%, var(--preview-surface)); color: var(--preview-text); background: var(--preview-surface); }
|
||||
.theme-preview-content i { display: block; width: 54%; height: 7px; background: var(--preview-text); opacity: .82; }
|
||||
.theme-preview-content b { display: block; width: 34px; height: 18px; grid-row: 1 / 3; grid-column: 2; background: var(--preview-accent); }
|
||||
.theme-preview-content em { display: block; width: 76%; height: 5px; background: var(--preview-text); opacity: .32; }
|
||||
.theme-color-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; }
|
||||
.theme-color-grid label { display: grid; grid-template-columns: minmax(0, 1fr) 32px auto; align-items: center; gap: 8px; min-width: 0; min-height: 38px; padding: 5px 7px; border: 1px solid var(--color-border-subtle); background: var(--color-surface-raised); }
|
||||
.theme-color-grid label > span { color: var(--color-ink-muted); font-size: 10px; font-weight: 750; }
|
||||
.theme-color-grid input[type="color"] { width: 32px; height: 25px; padding: 2px; border: 1px solid var(--color-border-input); background: transparent; cursor: pointer; }
|
||||
.theme-color-grid code { color: var(--color-ink-faint); font: 9px var(--font-mono); text-transform: uppercase; }
|
||||
.theme-generator-note { margin: -5px 0 0 !important; color: var(--color-ink-faint) !important; }
|
||||
.settings-switch-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; }
|
||||
.settings-switch-row span { display: grid; gap: 3px; }
|
||||
.settings-switch-row strong { color: var(--color-ink); font-size: 11px; }
|
||||
@@ -569,6 +668,7 @@
|
||||
.tool-rescan-button { align-self: flex-start; }
|
||||
.general-settings-grid { grid-template-columns: 1fr; }
|
||||
.general-setting-panel.general-setting-wide { grid-column: auto; }
|
||||
.theme-color-grid { grid-template-columns: 1fr; }
|
||||
.tool-config-panel { padding: 13px; }
|
||||
.tool-usage-callout { grid-template-columns: 1fr; gap: 4px; }
|
||||
}
|
||||
|
||||
@@ -11,20 +11,24 @@
|
||||
FileCog,
|
||||
FileImage,
|
||||
FileJson,
|
||||
FileMinus2,
|
||||
FileSearch,
|
||||
GitCompare,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileType,
|
||||
FileVideo,
|
||||
FileX,
|
||||
Folder,
|
||||
FolderMinus,
|
||||
FolderOpen,
|
||||
FolderX,
|
||||
ExternalLink,
|
||||
History,
|
||||
Terminal,
|
||||
} from "@lucide/svelte";
|
||||
import { languageIconForPath } from "../languageIcons";
|
||||
import type { ExplorerNode, ExplorerNodeKind, FileStatusKind, GitRepositoryFile } from "../types";
|
||||
import type { ExplorerNode, ExplorerNodeKind, FileStatusKind, GitIgnoreKind, GitRepositoryFile } from "../types";
|
||||
import LanguageIcon from "./LanguageIcon.svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -46,6 +50,8 @@
|
||||
onExternalDiff: (node: ExplorerNode) => void;
|
||||
onFileHistory: (node: ExplorerNode) => void;
|
||||
onBlame: (node: ExplorerNode) => void;
|
||||
onIgnore: (target: string, kind: GitIgnoreKind) => void;
|
||||
onStopTracking: (target: string, kind: "file" | "folder") => void;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapsed?: () => void;
|
||||
}
|
||||
@@ -69,6 +75,8 @@
|
||||
onExternalDiff = () => {},
|
||||
onFileHistory = () => {},
|
||||
onBlame = () => {},
|
||||
onIgnore = () => {},
|
||||
onStopTracking = () => {},
|
||||
collapsed = false,
|
||||
onToggleCollapsed = () => {},
|
||||
}: Props = $props();
|
||||
@@ -76,6 +84,7 @@
|
||||
let contextNode = $state<ExplorerNode | null>(null);
|
||||
let contextMenuX = $state(0);
|
||||
let contextMenuY = $state(0);
|
||||
let contextMenuElement = $state<HTMLDivElement | null>(null);
|
||||
const isGerman = $derived(language === "de");
|
||||
|
||||
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
|
||||
@@ -183,14 +192,19 @@
|
||||
return "text";
|
||||
}
|
||||
|
||||
function openFileContextMenu(event: MouseEvent, node: ExplorerNode) {
|
||||
if (node.kind !== "file") return;
|
||||
function openNodeContextMenu(event: MouseEvent, node: ExplorerNode) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
contextNode = node;
|
||||
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192));
|
||||
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 220));
|
||||
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 248));
|
||||
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 320));
|
||||
requestAnimationFrame(() => {
|
||||
if (!contextMenuElement) return;
|
||||
const bounds = contextMenuElement.getBoundingClientRect();
|
||||
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - bounds.width - 8));
|
||||
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - bounds.height - 8));
|
||||
});
|
||||
}
|
||||
|
||||
function closeFileContextMenu() {
|
||||
@@ -232,6 +246,31 @@
|
||||
onFileHistory(node);
|
||||
}
|
||||
|
||||
function explorerFileNodes(node: ExplorerNode): ExplorerNode[] {
|
||||
if (node.kind === "file") return [node];
|
||||
return node.children.flatMap(explorerFileNodes);
|
||||
}
|
||||
|
||||
function isIgnoreableExplorerFile(node: ExplorerNode): boolean {
|
||||
return node.kind === "file" && !node.tracked && node.path.replace(/\\/g, "/").toLowerCase() !== ".gitignore";
|
||||
}
|
||||
|
||||
function runContextIgnore(kind: GitIgnoreKind) {
|
||||
const node = contextNode;
|
||||
if (!node) return;
|
||||
if (kind === "folder" && node.kind !== "folder") return;
|
||||
if ((kind === "file" || kind === "extension") && node.kind !== "file") return;
|
||||
closeFileContextMenu();
|
||||
onIgnore(node.path, kind);
|
||||
}
|
||||
|
||||
function runContextStopTracking() {
|
||||
const node = contextNode;
|
||||
if (!node) return;
|
||||
closeFileContextMenu();
|
||||
onStopTracking(node.path, node.kind);
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") closeFileContextMenu();
|
||||
}
|
||||
@@ -239,6 +278,10 @@
|
||||
let explorerTree = $derived(buildExplorerTree(repoFiles));
|
||||
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
|
||||
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
|
||||
let contextFiles = $derived(contextNode ? explorerFileNodes(contextNode) : []);
|
||||
let contextCanIgnore = $derived(contextFiles.some(isIgnoreableExplorerFile));
|
||||
let contextCanStopTracking = $derived(contextFiles.some((node) => node.tracked));
|
||||
let contextIgnoreExtension = $derived(contextNode?.kind === "file" && contextCanIgnore ? extensionFor(contextNode.path) : "");
|
||||
let selectedFileNode = $derived(
|
||||
selectedExplorerKind === "file"
|
||||
? visibleNodes.find((node) => node.kind === "file" && node.path === selectedExplorerPath) ?? null
|
||||
@@ -338,7 +381,7 @@
|
||||
class:folder={node.kind === "folder"}
|
||||
style={`--depth: ${node.depth}`}
|
||||
title={node.path}
|
||||
oncontextmenu={(event) => openFileContextMenu(event, node)}
|
||||
oncontextmenu={(event) => openNodeContextMenu(event, node)}
|
||||
>
|
||||
{#if node.kind === "folder"}
|
||||
<button
|
||||
@@ -420,49 +463,60 @@
|
||||
|
||||
{#if contextNode}
|
||||
<div
|
||||
bind:this={contextMenuElement}
|
||||
class="explorer-context-menu"
|
||||
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
aria-label={`Actions for ${contextNode.path}`}
|
||||
>
|
||||
<button type="button" role="menuitem" onclick={openContextFileInEditor} disabled={contextNode.status === "deleted"}>
|
||||
<FileCode size={14} aria-hidden="true" />
|
||||
{isGerman ? `In ${editorName} öffnen` : `Open in ${editorName}`}
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={openContextExternalDiff} disabled={!contextNode.tracked || contextNode.status === "deleted"}>
|
||||
<GitCompare size={14} aria-hidden="true" />
|
||||
{isGerman ? `Mit HEAD in ${diffName} vergleichen` : `Compare with HEAD in ${diffName}`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={openContextFileHistory}
|
||||
disabled={!contextNode.tracked}
|
||||
title={contextNode.tracked ? "Show the commit history for this file" : "File history is only available for tracked files"}
|
||||
>
|
||||
<History size={14} aria-hidden="true" />
|
||||
File history
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={openContextFile}
|
||||
disabled={contextNode.status === "deleted"}
|
||||
title={contextNode.status === "deleted" ? "Deleted files cannot be revealed in Explorer" : "Reveal this file in Explorer"}
|
||||
>
|
||||
<ExternalLink size={14} aria-hidden="true" />
|
||||
Open in Explorer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={openContextBlame}
|
||||
disabled={!contextNode.tracked || contextNode.status === "deleted"}
|
||||
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
|
||||
>
|
||||
<FileSearch size={14} aria-hidden="true" />
|
||||
Blame
|
||||
</button>
|
||||
{#if contextNode.kind === "file"}
|
||||
<button type="button" role="menuitem" onclick={openContextFileInEditor} disabled={contextNode.status === "deleted"}>
|
||||
<FileCode size={14} aria-hidden="true" />
|
||||
{isGerman ? `In ${editorName} öffnen` : `Open in ${editorName}`}
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={openContextExternalDiff} disabled={!contextNode.tracked || contextNode.status === "deleted"}>
|
||||
<GitCompare size={14} aria-hidden="true" />
|
||||
{isGerman ? `Mit HEAD in ${diffName} vergleichen` : `Compare with HEAD in ${diffName}`}
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={openContextFileHistory} disabled={!contextNode.tracked} title={contextNode.tracked ? "Show the commit history for this file" : "File history is only available for tracked files"}>
|
||||
<History size={14} aria-hidden="true" />
|
||||
{isGerman ? "Dateiverlauf" : "File history"}
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={openContextFile} disabled={contextNode.status === "deleted"} title={contextNode.status === "deleted" ? "Deleted files cannot be revealed in Explorer" : "Reveal this file in Explorer"}>
|
||||
<ExternalLink size={14} aria-hidden="true" />
|
||||
{isGerman ? "Im Explorer öffnen" : "Open in Explorer"}
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={openContextBlame} disabled={!contextNode.tracked || contextNode.status === "deleted"} title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}>
|
||||
<FileSearch size={14} aria-hidden="true" />
|
||||
Blame
|
||||
</button>
|
||||
{/if}
|
||||
{#if contextCanStopTracking || contextCanIgnore}
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
{/if}
|
||||
{#if contextCanStopTracking}
|
||||
<button class="untrack" type="button" role="menuitem" onclick={runContextStopTracking} disabled={isBusy} title="Keep the working-tree content and remove it from the Git index">
|
||||
{#if contextNode.kind === "folder"}<FolderMinus size={14} aria-hidden="true" />{:else}<FileMinus2 size={14} aria-hidden="true" />{/if}
|
||||
{isGerman ? `${contextNode.kind === "folder" ? "Ordner" : "Datei"} nicht mehr tracken` : `Stop tracking ${contextNode.kind}`}
|
||||
</button>
|
||||
{/if}
|
||||
{#if contextCanIgnore && contextNode.kind === "file"}
|
||||
<button class="ignore" type="button" role="menuitem" onclick={() => runContextIgnore("file")} disabled={isBusy} title={`Add /${contextNode.path.replace(/\\/g, "/")} to .gitignore`}>
|
||||
<FileX size={14} aria-hidden="true" />
|
||||
{isGerman ? "Datei ignorieren" : "Ignore file"}
|
||||
</button>
|
||||
{#if contextIgnoreExtension}
|
||||
<button class="ignore" type="button" role="menuitem" onclick={() => runContextIgnore("extension")} disabled={isBusy} title={`Add *.${contextIgnoreExtension} to .gitignore`}>
|
||||
<FileType size={14} aria-hidden="true" />
|
||||
{isGerman ? `Alle *.${contextIgnoreExtension}-Dateien ignorieren` : `Ignore all *.${contextIgnoreExtension} files`}
|
||||
</button>
|
||||
{/if}
|
||||
{:else if contextCanIgnore && contextNode.kind === "folder"}
|
||||
<button class="ignore" type="button" role="menuitem" onclick={() => runContextIgnore("folder")} disabled={isBusy} title={`Add /${contextNode.path.replace(/\\/g, "/").replace(/\/+$/, "")}/ to .gitignore`}>
|
||||
<FolderX size={14} aria-hidden="true" />
|
||||
{isGerman ? "Ordner ignorieren" : "Ignore folder"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArchiveRestore,
|
||||
Box,
|
||||
Check,
|
||||
CircleDashed,
|
||||
Download,
|
||||
HardDriveDownload,
|
||||
Link2,
|
||||
LoaderCircle,
|
||||
PackageCheck,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
import type { AppLanguage, GitLfsPattern, GitLfsStatus } from "../types";
|
||||
|
||||
interface Props {
|
||||
status: GitLfsStatus | null;
|
||||
language: AppLanguage;
|
||||
isLoading: boolean;
|
||||
isBusy: boolean;
|
||||
error?: string;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
onInstall: () => boolean | void | Promise<boolean | void>;
|
||||
onTrack: (pattern: string, lockable: boolean) => boolean | Promise<boolean>;
|
||||
onUntrack: (pattern: string) => void | Promise<void>;
|
||||
onPull: () => void | Promise<void>;
|
||||
onPrune: () => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
status = null,
|
||||
language = "en",
|
||||
isLoading = false,
|
||||
isBusy = false,
|
||||
error = "",
|
||||
onRefresh = () => {},
|
||||
onInstall = () => {},
|
||||
onTrack = () => false,
|
||||
onUntrack = () => {},
|
||||
onPull = () => {},
|
||||
onPrune = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let pattern = $state("");
|
||||
let lockable = $state(false);
|
||||
let isGerman = $derived(language === "de");
|
||||
let setupReady = $derived(Boolean(status?.filters_configured && status?.hook_installed));
|
||||
let downloadedCount = $derived(status?.files.filter((file) => file.downloaded).length ?? 0);
|
||||
let totalSize = $derived(status?.files.reduce((sum, file) => sum + file.size, 0) ?? 0);
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (!value) return "—";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||
const amount = value / 1024 ** index;
|
||||
return `${amount >= 10 || index === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function versionLabel(value: string | null | undefined): string {
|
||||
return value?.match(/git-lfs\/([^\s]+)/)?.[1] ?? value ?? "—";
|
||||
}
|
||||
|
||||
function canRemove(item: GitLfsPattern): boolean {
|
||||
return item.source === ".gitattributes";
|
||||
}
|
||||
|
||||
async function submitPattern(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const value = pattern.trim();
|
||||
if (!value || !setupReady) return;
|
||||
if (await onTrack(value, lockable)) {
|
||||
pattern = "";
|
||||
lockable = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPrune() {
|
||||
const confirmed = window.confirm(isGerman
|
||||
? "Nicht mehr benötigte lokale LFS-Objekte sicher bereinigen? Nicht gepushte und aktuell verwendete Objekte bleiben erhalten."
|
||||
: "Safely prune unused local LFS objects? Unpushed and currently used objects are retained.");
|
||||
if (confirmed) await onPrune();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog lfs-dialog" role="dialog" aria-modal="true" aria-labelledby="lfs-dialog-title">
|
||||
<header class="dialog-header lfs-dialog-header">
|
||||
<div class="lfs-heading">
|
||||
<span class="lfs-mark" aria-hidden="true"><Box size={19} /></span>
|
||||
<div>
|
||||
<span class="eyebrow">Large file storage</span>
|
||||
<p class="dialog-title" id="lfs-dialog-title">Git LFS</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>
|
||||
<RefreshCw class={isLoading ? "spin" : undefined} size={15} aria-hidden="true" />
|
||||
{isGerman ? "Prüfen" : "Check"}
|
||||
</button>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label={isGerman ? "Schließen" : "Close"}>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="lfs-main">
|
||||
{#if error}
|
||||
<div class="lfs-error" role="alert"><AlertTriangle size={15} aria-hidden="true" />{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading && !status}
|
||||
<div class="lfs-loading"><LoaderCircle class="spin" size={22} aria-hidden="true" /><span>{isGerman ? "Git LFS wird geprüft…" : "Inspecting Git LFS…"}</span></div>
|
||||
{:else if status}
|
||||
<div class="lfs-content">
|
||||
<section class="lfs-diagnostics" aria-label={isGerman ? "LFS-Diagnose" : "LFS diagnostics"}>
|
||||
<article class:ready={status.available} class:error={!status.available}>
|
||||
<span class="diagnostic-icon">{#if status.available}<PackageCheck size={18} />{:else}<AlertTriangle size={18} />{/if}</span>
|
||||
<div><small>01 · {isGerman ? "Erweiterung" : "Extension"}</small><strong>{status.available ? `Git LFS ${versionLabel(status.version)}` : (isGerman ? "Nicht gefunden" : "Not found")}</strong><span>{status.bundled ? (isGerman ? "Mit Gitty gebündelt" : "Bundled with Gitty") : (isGerman ? "Systeminstallation" : "System installation")}</span></div>
|
||||
</article>
|
||||
<span class:ready={status.available} class="diagnostic-link" aria-hidden="true"><Link2 size={14} /></span>
|
||||
<article class:ready={setupReady} class:warning={status.available && !setupReady}>
|
||||
<span class="diagnostic-icon">{#if setupReady}<ShieldCheck size={18} />{:else}<CircleDashed size={18} />{/if}</span>
|
||||
<div><small>02 · Repository</small><strong>{setupReady ? (isGerman ? "Aktiv" : "Active") : (isGerman ? "Einrichtung nötig" : "Setup required")}</strong><span>{status.filters_configured ? "Filter ✓" : "Filter —"} · {status.hook_installed ? "Pre-push ✓" : "Pre-push —"}</span></div>
|
||||
</article>
|
||||
<span class:ready={setupReady} class="diagnostic-link" aria-hidden="true"><Link2 size={14} /></span>
|
||||
<article class:ready={status.files.length > 0}>
|
||||
<span class="diagnostic-icon"><ArchiveRestore size={18} /></span>
|
||||
<div><small>03 · {isGerman ? "Objekte" : "Objects"}</small><strong>{status.files.length} {isGerman ? "Dateien" : "files"}</strong><span>{downloadedCount} {isGerman ? "lokal" : "local"} · {formatBytes(totalSize)}</span></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{#if !status.available}
|
||||
<section class="lfs-missing">
|
||||
<span class="lfs-missing-icon"><AlertTriangle size={22} aria-hidden="true" /></span>
|
||||
<div>
|
||||
<strong>{isGerman ? "Git LFS ist nicht ausführbar" : "Git LFS cannot be started"}</strong>
|
||||
<p>{isGerman ? "Gitty liefert Git LFS normalerweise mit. Installiere Gitty erneut oder installiere git-lfs systemweit und starte die App neu." : "Gitty normally includes Git LFS. Reinstall Gitty or install git-lfs system-wide, then restart the app."}</p>
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
{#if !setupReady}
|
||||
<section class="lfs-setup-card">
|
||||
<div><strong>{isGerman ? "Für dieses Repository aktivieren" : "Activate for this repository"}</strong><p>{isGerman ? "Richtet Clean-/Smudge-Filter lokal ein und installiert den Pre-push-Hook. Globale Git-Einstellungen bleiben unverändert." : "Configures local clean/smudge filters and installs the pre-push hook. Global Git settings remain unchanged."}</p></div>
|
||||
<button class="btn-primary" type="button" onclick={onInstall} disabled={isBusy}><ShieldCheck size={15} aria-hidden="true" />{isGerman ? "LFS aktivieren" : "Activate LFS"}</button>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="lfs-grid">
|
||||
<section class="lfs-panel patterns-panel">
|
||||
<header><div><span class="eyebrow">Tracking rules</span><h3>{isGerman ? "Muster" : "Patterns"}</h3></div><span>{status.patterns.length}</span></header>
|
||||
<form class="lfs-track-form" onsubmit={submitPattern}>
|
||||
<label><span>{isGerman ? "Neues Muster" : "New pattern"}</span><input type="text" bind:value={pattern} disabled={isBusy || !setupReady} autocomplete="off" spellcheck="false" placeholder="*.psd, Assets/**, video.mp4" /></label>
|
||||
<label class="lfs-lockable"><input type="checkbox" bind:checked={lockable} disabled={isBusy || !setupReady} /><span><strong>Lockable</strong><small>{isGerman ? "Schreibgeschützt, solange nicht gesperrt" : "Read-only until locked"}</small></span></label>
|
||||
<button class="btn-primary" type="submit" disabled={isBusy || !setupReady || !pattern.trim()}><Plus size={15} aria-hidden="true" />{isGerman ? "Hinzufügen" : "Add"}</button>
|
||||
</form>
|
||||
|
||||
<div class="lfs-pattern-list">
|
||||
{#if status.patterns.length === 0}
|
||||
<div class="lfs-empty"><Box size={20} aria-hidden="true" /><span>{isGerman ? "Noch keine Dateien werden über LFS verwaltet." : "No files are tracked through LFS yet."}</span></div>
|
||||
{:else}
|
||||
{#each status.patterns as item (`${item.source}:${item.pattern}`)}
|
||||
<article>
|
||||
<div><code>{item.pattern}</code><span>{item.source}{item.lockable ? " · lockable" : ""}</span></div>
|
||||
<button class="btn-sm danger" type="button" onclick={() => onUntrack(item.pattern)} disabled={isBusy || !canRemove(item)} title={canRemove(item) ? (isGerman ? "Muster entfernen" : "Remove pattern") : (isGerman ? "Nur Muster aus der Wurzel-.gitattributes können hier entfernt werden" : "Only patterns from the root .gitattributes can be removed here") }><Trash2 size={14} aria-hidden="true" /></button>
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lfs-panel objects-panel">
|
||||
<header><div><span class="eyebrow">Current checkout</span><h3>{isGerman ? "LFS-Dateien" : "LFS files"}</h3></div><span>{status.files.length}</span></header>
|
||||
<div class="lfs-object-list">
|
||||
{#if status.files.length === 0}
|
||||
<div class="lfs-empty"><HardDriveDownload size={20} aria-hidden="true" /><span>{isGerman ? "Im aktuellen Stand sind keine LFS-Objekte vorhanden." : "The current checkout has no LFS objects."}</span></div>
|
||||
{:else}
|
||||
{#each status.files as file (`${file.oid}:${file.name}`)}
|
||||
<article>
|
||||
<span class:downloaded={file.downloaded} class="object-state" title={file.downloaded ? (isGerman ? "Objekt lokal vorhanden" : "Object available locally") : (isGerman ? "Nur LFS-Zeiger vorhanden" : "LFS pointer only")}>{#if file.downloaded}<Check size={12} />{:else}<Download size={12} />{/if}</span>
|
||||
<div title={file.name}><strong>{file.name}</strong><span>{formatBytes(file.size)} · <code>{file.oid.slice(0, 10)}</code></span></div>
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer class="lfs-footer">
|
||||
<div><ShieldCheck size={14} aria-hidden="true" /><span>{isGerman ? ".gitattributes bleibt als normale Änderung sichtbar und muss committed werden." : ".gitattributes remains a normal change and must be committed."}</span></div>
|
||||
<div>
|
||||
<button class="btn-secondary" type="button" onclick={confirmPrune} disabled={isBusy || !status?.available}><Trash2 size={14} aria-hidden="true" />{isGerman ? "Cache bereinigen" : "Prune cache"}</button>
|
||||
<button class="btn-primary" type="button" onclick={onPull} disabled={isBusy || !setupReady}><HardDriveDownload size={15} aria-hidden="true" />{isGerman ? "Objekte laden" : "Pull objects"}</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.lfs-dialog { --lfs-success: #4eca76; --lfs-warning: #f0b648; --lfs-danger: #e86060; width: min(880px, calc(100vw - 32px)); height: auto; max-height: min(660px, calc(100vh - 32px)); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; border-radius: 12px; }
|
||||
.lfs-dialog-header, .lfs-heading, .dialog-header-actions, .lfs-footer, .lfs-footer > div { display: flex; align-items: center; }
|
||||
.lfs-dialog-header { min-height: 58px; justify-content: space-between; padding: 10px 13px; }
|
||||
.lfs-heading { gap: 9px; }
|
||||
.lfs-mark { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid color-mix(in srgb, var(--color-accent) 34%, var(--color-border)); border-radius: 9px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
|
||||
.dialog-header-actions { gap: 7px; }
|
||||
.lfs-main { min-height: 0; overflow: hidden; }
|
||||
.lfs-error { display: flex; align-items: center; gap: 8px; margin: 10px 13px 0; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--lfs-danger) 38%, var(--color-border)); border-radius: 7px; color: var(--lfs-danger); background: color-mix(in srgb, var(--lfs-danger) 8%, transparent); font-size: 11px; }
|
||||
.lfs-loading { display: grid; min-height: 280px; place-items: center; align-content: center; gap: 10px; color: var(--color-ink-dim); font-size: 12px; }
|
||||
.lfs-content { min-height: 0; max-height: 548px; padding: 12px 13px 13px; overflow: auto; }
|
||||
.lfs-diagnostics { display: grid; grid-template-columns: minmax(0, 1fr) 22px minmax(0, 1fr) 22px minmax(0, 1fr); align-items: center; margin-bottom: 11px; }
|
||||
.lfs-diagnostics article { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 9px; min-height: 64px; padding: 8px 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
|
||||
.lfs-diagnostics article.ready { border-color: color-mix(in srgb, var(--lfs-success) 34%, var(--color-border)); }
|
||||
.lfs-diagnostics article.warning { border-color: color-mix(in srgb, var(--lfs-warning) 38%, var(--color-border)); }
|
||||
.lfs-diagnostics article.error { border-color: color-mix(in srgb, var(--lfs-danger) 38%, var(--color-border)); }
|
||||
.diagnostic-icon { display: grid; width: 29px; height: 29px; place-items: center; border-radius: 8px; color: var(--color-ink-dim); background: var(--color-surface-hover); }
|
||||
article.ready .diagnostic-icon { color: var(--lfs-success); background: color-mix(in srgb, var(--lfs-success) 10%, transparent); }
|
||||
article.warning .diagnostic-icon { color: var(--lfs-warning); }
|
||||
article.error .diagnostic-icon { color: var(--lfs-danger); }
|
||||
.lfs-diagnostics article div { display: grid; min-width: 0; gap: 2px; }
|
||||
.lfs-diagnostics small { color: var(--color-ink-faint); font-size: 9px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.lfs-diagnostics strong { overflow: hidden; color: var(--color-ink); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lfs-diagnostics article div > span { color: var(--color-ink-dim); font-size: 10px; }
|
||||
.diagnostic-link { display: grid; place-items: center; color: var(--color-border); }
|
||||
.diagnostic-link.ready { color: color-mix(in srgb, var(--lfs-success) 60%, var(--color-border)); }
|
||||
.lfs-missing, .lfs-setup-card { display: flex; align-items: center; gap: 11px; padding: 12px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
|
||||
.lfs-missing-icon { display: grid; flex: 0 0 auto; place-items: center; color: var(--lfs-danger); }
|
||||
.lfs-missing strong, .lfs-setup-card strong { color: var(--color-ink); font-size: 12px; }
|
||||
.lfs-missing p, .lfs-setup-card p { margin: 4px 0 0; color: var(--color-ink-dim); font-size: 10.5px; line-height: 1.5; }
|
||||
.lfs-setup-card { justify-content: space-between; margin-bottom: 11px; border-color: color-mix(in srgb, var(--lfs-warning) 32%, var(--color-border)); }
|
||||
.lfs-setup-card > div { max-width: 650px; }
|
||||
.lfs-setup-card button { flex: 0 0 auto; }
|
||||
.lfs-grid { display: grid; grid-template-columns: minmax(0, .92fr) minmax(0, 1.08fr); gap: 10px; height: clamp(230px, 30vh, 285px); min-height: 0; }
|
||||
.lfs-panel { display: grid; min-height: 0; grid-template-rows: auto auto minmax(0, 1fr); border: 1px solid var(--color-border-subtle); border-radius: 10px; overflow: hidden; background: var(--color-surface-raised); }
|
||||
.objects-panel { grid-template-rows: auto minmax(0, 1fr); }
|
||||
.lfs-panel > header { display: flex; align-items: center; justify-content: space-between; min-height: 48px; padding: 8px 10px; border-bottom: 1px solid var(--color-border-subtle); background: color-mix(in srgb, var(--app-dialog-chrome) 74%, transparent); }
|
||||
.lfs-panel h3 { margin: 2px 0 0; color: var(--color-ink); font-size: 13px; }
|
||||
.lfs-panel > header > span { display: grid; min-width: 25px; height: 22px; place-items: center; border-radius: 11px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 10%, transparent); font-size: 10px; font-weight: 800; }
|
||||
.lfs-track-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 7px 9px; padding: 9px 10px; border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.lfs-track-form > label:first-child { display: grid; gap: 5px; grid-column: 1 / -1; }
|
||||
.lfs-track-form label > span:first-child { color: var(--color-ink-dim); font-size: 9.5px; font-weight: 700; }
|
||||
.lfs-track-form input[type="text"] { width: 100%; }
|
||||
.lfs-lockable { display: inline-flex; width: fit-content; align-items: center; gap: 6px; cursor: pointer; user-select: none; }
|
||||
.lfs-lockable input[type="checkbox"] { width: 14px; min-width: 14px; height: 14px; margin: 0; padding: 0; border-radius: 3px; accent-color: var(--color-accent); cursor: pointer; box-shadow: none; }
|
||||
.lfs-lockable > span { display: flex; align-items: baseline; gap: 4px; white-space: nowrap; }
|
||||
.lfs-lockable strong { color: var(--color-ink); font-size: 10px; }
|
||||
.lfs-lockable small { color: var(--color-ink-faint); font-size: 8.5px; }
|
||||
.lfs-pattern-list, .lfs-object-list { min-height: 0; overflow: auto; }
|
||||
.lfs-pattern-list article, .lfs-object-list article { display: flex; align-items: center; gap: 9px; min-height: 49px; padding: 8px 10px; border-bottom: 1px solid var(--color-border-subtle); }
|
||||
.lfs-pattern-list article:last-child, .lfs-object-list article:last-child { border-bottom: 0; }
|
||||
.lfs-pattern-list article > div, .lfs-object-list article > div { display: grid; min-width: 0; flex: 1; gap: 3px; }
|
||||
.lfs-pattern-list code, .lfs-object-list strong { overflow: hidden; color: var(--color-ink); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lfs-pattern-list article span, .lfs-object-list article span { color: var(--color-ink-faint); font-size: 9px; }
|
||||
.object-state { display: grid; width: 24px; height: 24px; flex: 0 0 auto; place-items: center; border-radius: 50%; color: var(--lfs-warning); background: color-mix(in srgb, var(--lfs-warning) 10%, transparent); }
|
||||
.object-state.downloaded { color: var(--lfs-success); background: color-mix(in srgb, var(--lfs-success) 10%, transparent); }
|
||||
.lfs-empty { display: grid; width: 100%; height: 100%; min-height: 80px; place-items: center; align-content: center; gap: 7px; padding: 14px; color: var(--color-ink-faint); text-align: center; }
|
||||
.lfs-empty span { max-width: 260px; font-size: 10px; line-height: 1.45; }
|
||||
.lfs-footer { min-height: 54px; justify-content: space-between; gap: 14px; padding: 9px 13px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
||||
.lfs-footer > div { gap: 7px; }
|
||||
.lfs-footer > div:first-child { min-width: 0; color: var(--color-ink-dim); font-size: 9.5px; }
|
||||
.lfs-footer > div:last-child { flex: 0 0 auto; gap: 8px; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.dialog-backdrop { padding: 10px; }
|
||||
.lfs-dialog { width: min(620px, 100%); height: min(760px, 100%); max-height: none; }
|
||||
.lfs-content { height: 100%; max-height: none; }
|
||||
.lfs-diagnostics { grid-template-columns: 1fr; gap: 7px; }
|
||||
.diagnostic-link { display: none; }
|
||||
.lfs-grid { grid-template-columns: 1fr; height: auto; }
|
||||
.lfs-panel { min-height: 210px; }
|
||||
.lfs-footer { align-items: flex-start; flex-direction: column; }
|
||||
.lfs-footer > div:last-child { width: 100%; justify-content: flex-end; }
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.lfs-dialog-header { min-height: 54px; }
|
||||
.lfs-mark { width: 31px; height: 31px; }
|
||||
.lfs-track-form { grid-template-columns: 1fr; }
|
||||
.lfs-track-form > label:first-child { grid-column: 1; }
|
||||
.lfs-track-form button { justify-self: end; }
|
||||
.lfs-lockable > span { white-space: normal; }
|
||||
.lfs-footer > div:first-child { display: none; }
|
||||
.lfs-footer > div:last-child { justify-content: stretch; }
|
||||
.lfs-footer button { flex: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
BookOpen,
|
||||
Box,
|
||||
Check,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
@@ -468,6 +469,128 @@
|
||||
},
|
||||
];
|
||||
|
||||
deCategories.splice(deCategories.findIndex((category) => category.id === "remote") + 1, 0, {
|
||||
id: "lfs",
|
||||
label: "Git LFS",
|
||||
description: "Große Binärdateien tracken, Objekte synchronisieren und bestehende Repositories sicher umstellen.",
|
||||
sections: [
|
||||
{
|
||||
id: "lfs-overview",
|
||||
title: "Was Git LFS macht",
|
||||
summary: "Git LFS ersetzt große Dateien im Git-Verlauf durch kleine Zeigerdateien. Die eigentlichen Inhalte liegen im LFS-Speicher des Remotes und werden beim Checkout oder Pull passend geladen.",
|
||||
steps: [
|
||||
"Nutze LFS vor allem für große Binärdateien wie PSD-, Video-, Audio-, Modell- oder Archivdateien, die Git nicht sinnvoll als Text-Diff verwalten kann.",
|
||||
"Gitty liefert die Git-LFS-Erweiterung in Desktop-Installern mit und zeigt Version, Filter sowie Pre-push-Hook im LFS-Dialog an.",
|
||||
"Die LFS-Regeln stehen in .gitattributes und gehören deshalb wie normaler Quellcode in das Repository.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lfs-setup",
|
||||
title: "Git LFS in Gitty einrichten",
|
||||
summary: "Die Einrichtung gilt für das aktuell geöffnete Repository und verändert keine globalen Git-Einstellungen.",
|
||||
steps: [
|
||||
"Öffne im Repository das Menü Synchronisieren und wähle Git LFS.",
|
||||
"Klicke auf LFS aktivieren, damit Gitty die lokalen Filter und den Pre-push-Hook einrichtet.",
|
||||
"Füge ein Muster wie *.psd, Assets/** oder video.mp4 hinzu. Lockable markiert Dateien, die über einen kompatiblen LFS-Server gesperrt werden können.",
|
||||
"Stage und committe anschließend .gitattributes zusammen mit den gewünschten Dateien.",
|
||||
],
|
||||
commands: [
|
||||
{ command: "git lfs install --local", description: "LFS nur im aktuellen Repository aktivieren" },
|
||||
{ command: "git lfs track \"*.psd\"", description: "Ein Dateimuster über Git LFS verwalten" },
|
||||
{ command: "git add .gitattributes", description: "Die erzeugten Tracking-Regeln stagen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lfs-sync",
|
||||
title: "LFS-Objekte synchronisieren",
|
||||
summary: "Clone und Pull prüfen in Gitty automatisch auf LFS und laden benötigte Objekte mit demselben Remote und denselben Zugangsdaten. Ein zweiter Pull ist nicht nötig.",
|
||||
steps: [
|
||||
"Neue Klone aus der Oberfläche sowie über --clone aktivieren LFS lokal und laden die Objekte von origin, bevor das Repository geöffnet wird.",
|
||||
"Normales Push nutzt den LFS-Pre-push-Hook und lädt neue LFS-Objekte vor den Git-Referenzen hoch.",
|
||||
"Objekte laden im LFS-Dialog ist ein manueller Reparatur- oder Aktualisierungsschritt, falls lokale Inhalte fehlen.",
|
||||
"Cache bereinigen entfernt sicher nicht mehr benötigte lokale Objekte; aktuell verwendete und noch nicht gepushte Inhalte bleiben erhalten.",
|
||||
],
|
||||
commands: [
|
||||
{ command: "git lfs status", description: "LFS-Zustand und ausstehende Änderungen prüfen" },
|
||||
{ command: "git lfs pull", description: "Benötigte LFS-Objekte manuell laden" },
|
||||
{ command: "git lfs prune", description: "Nicht mehr benötigte lokale LFS-Objekte bereinigen" },
|
||||
{ command: "git lfs lock <datei>", description: "Eine als lockable markierte Datei auf einem kompatiblen Remote sperren" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lfs-migrate",
|
||||
title: "Bestehende Dateien umstellen",
|
||||
summary: "Ein neu hinzugefügtes Tracking-Muster schreibt vorhandene Commits nicht rückwirkend um. Aktuelle Dateien lassen sich neu normalisieren; eine vollständige Migration verändert dagegen die Historie.",
|
||||
commands: [
|
||||
{ command: "git add --renormalize .", description: "Aktuelle Dateien erneut durch die neuen LFS-Regeln führen" },
|
||||
{ command: "git lfs migrate import --include=\"*.psd\" --everything", description: "Passende Dateien in der gesamten Historie nach LFS migrieren" },
|
||||
],
|
||||
note: "Vorsicht: git lfs migrate import schreibt Commit-Hashes um. Stimme die Migration mit allen Beteiligten ab, erstelle vorher ein Backup und rechne bei bereits veröffentlichten Branches mit einem koordinierten Force-Push.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
enCategories.splice(enCategories.findIndex((category) => category.id === "remote") + 1, 0, {
|
||||
id: "lfs",
|
||||
label: "Git LFS",
|
||||
description: "Track large binary files, synchronize objects, and migrate existing repositories safely.",
|
||||
sections: [
|
||||
{
|
||||
id: "lfs-overview",
|
||||
title: "What Git LFS does",
|
||||
summary: "Git LFS replaces large files in Git history with small pointer files. The actual content is stored in the remote's LFS storage and downloaded for the relevant checkout or pull.",
|
||||
steps: [
|
||||
"Use LFS mainly for large binary files such as PSDs, videos, audio, models, or archives that Git cannot usefully manage as text diffs.",
|
||||
"Gitty bundles the Git LFS extension in desktop installers and displays its version, filters, and pre-push hook in the LFS dialog.",
|
||||
"LFS rules live in .gitattributes, so commit them to the repository like regular source code.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lfs-setup",
|
||||
title: "Set up Git LFS in Gitty",
|
||||
summary: "Setup applies to the currently open repository and does not change global Git settings.",
|
||||
steps: [
|
||||
"Open the Sync menu in the repository and select Git LFS.",
|
||||
"Select Activate LFS so Gitty configures the local filters and pre-push hook.",
|
||||
"Add a pattern such as *.psd, Assets/**, or video.mp4. Lockable marks files that can be locked through a compatible LFS server.",
|
||||
"Stage and commit .gitattributes together with the files you want to track.",
|
||||
],
|
||||
commands: [
|
||||
{ command: "git lfs install --local", description: "Activate LFS only in the current repository" },
|
||||
{ command: "git lfs track \"*.psd\"", description: "Manage a file pattern through Git LFS" },
|
||||
{ command: "git add .gitattributes", description: "Stage the generated tracking rules" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lfs-sync",
|
||||
title: "Synchronize LFS objects",
|
||||
summary: "Clone and pull automatically check for LFS in Gitty and download required objects with the same remote and credentials. A second pull is not necessary.",
|
||||
steps: [
|
||||
"Fresh clones from the UI and --clone activate LFS locally and download objects from origin before the repository is opened.",
|
||||
"A normal push uses the LFS pre-push hook to upload new LFS objects before Git references are published.",
|
||||
"Pull objects in the LFS dialog is a manual repair or refresh action when local content is missing.",
|
||||
"Prune cache safely removes unused local objects while retaining current and unpushed content.",
|
||||
],
|
||||
commands: [
|
||||
{ command: "git lfs status", description: "Inspect LFS state and pending changes" },
|
||||
{ command: "git lfs pull", description: "Download required LFS objects manually" },
|
||||
{ command: "git lfs prune", description: "Remove unused local LFS objects" },
|
||||
{ command: "git lfs lock <file>", description: "Lock a lockable file on a compatible remote" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lfs-migrate",
|
||||
title: "Migrate existing files",
|
||||
summary: "Adding a tracking pattern does not rewrite existing commits. Current files can be renormalized, while a complete migration changes repository history.",
|
||||
commands: [
|
||||
{ command: "git add --renormalize .", description: "Run current files through the new LFS rules again" },
|
||||
{ command: "git lfs migrate import --include=\"*.psd\" --everything", description: "Move matching files to LFS throughout repository history" },
|
||||
],
|
||||
note: "Caution: git lfs migrate import rewrites commit hashes. Coordinate the migration with every contributor, create a backup first, and expect a coordinated force push for published branches.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Extended handbook chapters. Keeping these additions next to the shared data makes
|
||||
// it straightforward to compare the German and English coverage section by section.
|
||||
deCategories.find((category) => category.id === "start")?.sections.push(
|
||||
@@ -1399,6 +1522,36 @@
|
||||
label: "Neu in Gitty",
|
||||
description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.",
|
||||
sections: [
|
||||
{
|
||||
id: "changelog-2026-8-6",
|
||||
title: "Version 2026.8.6",
|
||||
summary: "Dieses Wartungsrelease stabilisiert Git-LFS-Workflows vom Tracking über Clone und Pull bis zum Push großer Dateien nach Azure DevOps.",
|
||||
steps: [
|
||||
"LFS-Muster aus der .gitattributes im Repository-Stamm bleiben im LFS-Dialog sichtbar, auch wenn die Datei noch ungetrackt ist oder zuvor durch eine Ignore-Regel ausgeblendet wurde.",
|
||||
"Beim Aktivieren von Git LFS und beim Hinzufügen eines Tracking-Musters stellt Gitty sicher, dass die .gitattributes nicht ignoriert wird. Nur wenn nötig, wird die gezielte Ausnahme !/.gitattributes am Ende der .gitignore ergänzt.",
|
||||
"Clone und Pull verwenden für erkannte LFS-Repositories denselben Remote und dieselben Zugangsdaten auch zum Laden der LFS-Objekte. Neue Klone aktivieren Filter und Pre-Push-Hook automatisch.",
|
||||
"Wenn Azure DevOps einen großen LFS-Upload über HTTP/2 mit HTTP 413 ablehnt, wiederholt Gitty den Push einmal mit einer nur für diesen Befehl geltenden HTTP/1.1-Konfiguration. Globale und Repository-Einstellungen bleiben unverändert.",
|
||||
"LFS-, Größen- und andere allgemeine Push-Fehler werden nicht mehr als Non-Fast-Forward verwechselt. Der unnötige Ablauf „Pull vor Push“ mit anschließendem „Push after pull“ erscheint nur noch bei einem tatsächlichen veralteten lokalen Branch.",
|
||||
"Der Tauri-Debug-Launcher entfernt ausschließlich bekannte nicht routende Test-Proxys aus dem Gitty-Unterprozess. Echte Benutzer- und Unternehmens-Proxys bleiben erhalten, sodass Remote- und LFS-Abläufe auch im Debug-Build testbar sind.",
|
||||
],
|
||||
note: "Die HTTP/1.1-Wiederholung greift nur nach einem LFS-Fehler 413. Änderungen an .gitattributes und .gitignore bleiben normale Repository-Änderungen und müssen committed und gepusht werden.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-5",
|
||||
title: "Version 2026.8.5",
|
||||
summary: "Dieses Release integriert Git LFS direkt in Gitty und macht den Staging-Bereich bei vielen geänderten Dateien deutlich übersichtlicher.",
|
||||
steps: [
|
||||
"Git LFS ist direkt über das Synchronisierungsmenü erreichbar. Gitty prüft die verfügbare Erweiterung, die Repository-Konfiguration und den Pre-Push-Hook und zeigt an, ob Git LFS mit Gitty gebündelt oder systemweit installiert ist.",
|
||||
"LFS-Muster lassen sich hinzufügen, als Lockable markieren und wieder entfernen. Der Dialog zeigt außerdem die LFS-Dateien des aktuellen Checkouts, lädt fehlende Objekte und bereinigt nicht mehr benötigte Cache-Objekte.",
|
||||
"Nach einem erfolgreichen Clone oder Pull erkennt Gitty LFS-Repositories automatisch und lädt die benötigten LFS-Objekte mit demselben Remote und denselben Zugangsdaten. Neue Klone aktivieren LFS außerdem lokal, sodass kein zweiter manueller Pull erforderlich ist.",
|
||||
"Unstaged und Staged stehen jetzt gleich breit nebeneinander, scrollen unabhängig voneinander und verwenden eindeutige Pfeile für Stage und Unstage. Bei schmalen Fenstern wechselt die Darstellung automatisch untereinander.",
|
||||
"Der mittig angeordnete List-/Tree-Umschalter zeigt Änderungen entweder als kompakte Liste oder gruppiert sie in beiden Bereichen nach aufklappbaren Ordnern.",
|
||||
"Über das neu gestaltete Kontextmenü einer Datei oder eines Ordners lassen sich gezielt einzelne Dateien oder alle Änderungen im Ordner stagen, unstagen oder in einem eigenen Stash sichern. Neue und ungetrackte Inhalte können im Changes-Bereich und im File Explorer als exakte Datei, kompletter Ordner oder Dateiendungs-Muster in die .gitignore übernommen werden; die Ordneroption erscheint nur beim Rechtsklick auf einen Ordner. Bereits getrackte Dateien und Ordner lassen sich mit „Stop tracking“ aus dem Git-Index entfernen, bleiben aber auf der Festplatte erhalten. Dateiname, übergeordneter Pfad und Anzahl der betroffenen Dateien sind dabei klar voneinander getrennt.",
|
||||
"Repositories können beim Start über --repo PATH oder --repo=PATH direkt geöffnet werden. Mit clone REMOTE ZIEL, --clone REMOTE ZIEL oder --clone=REMOTE ZIEL klont Gitty ein Remote-Repository in den exakt angegebenen lokalen Ordner und öffnet es anschließend. Relative Pfade werden gegen das aktuelle Arbeitsverzeichnis aufgelöst; der Aufruf wird auch an eine bereits laufende Gitty-Instanz weitergegeben.",
|
||||
"Quadratische Bedienelemente und Flächen vereinheitlichen das Erscheinungsbild; runde Statuspunkte, Avatare und charakteristische Branch-Markierungen bleiben erhalten.",
|
||||
],
|
||||
note: "Von Git LFS erzeugte Änderungen an .gitattributes gehören zum Repository und müssen wie jede andere Änderung committed werden. Bereits vorhandene Git-Historie wird durch neue Tracking-Muster nicht rückwirkend umgeschrieben.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-4",
|
||||
title: "Version 2026.8.4",
|
||||
@@ -1499,6 +1652,36 @@
|
||||
label: "What's new",
|
||||
description: "Changes since the latest published version and notable additions from earlier releases.",
|
||||
sections: [
|
||||
{
|
||||
id: "changelog-2026-8-6",
|
||||
title: "Version 2026.8.6",
|
||||
summary: "This maintenance release stabilizes Git LFS workflows from tracking through clone and pull to pushing large files to Azure DevOps.",
|
||||
steps: [
|
||||
"LFS patterns from the root .gitattributes remain visible in the LFS dialog even while the file is untracked or was previously hidden by an ignore rule.",
|
||||
"When Git LFS is activated or a tracking pattern is added, Gitty ensures that .gitattributes is not ignored. Only when required, the scoped !/.gitattributes exception is appended to .gitignore.",
|
||||
"Clone and pull use the same remote and credentials to download LFS objects for detected LFS repositories. Fresh clones also activate the filters and pre-push hook automatically.",
|
||||
"When Azure DevOps rejects a large LFS upload over HTTP/2 with HTTP 413, Gitty retries the push once with an HTTP/1.1 setting scoped to that command. Global and repository settings remain unchanged.",
|
||||
"LFS, size, and other generic push failures are no longer mistaken for non-fast-forward rejections. The Pull before push and Push after pull flow is now offered only when the local branch is genuinely behind its remote.",
|
||||
"The Tauri debug launcher removes only known non-routing test proxies from the Gitty child process. Real user and company proxies are preserved, keeping remote and LFS workflows testable in debug builds.",
|
||||
],
|
||||
note: "The HTTP/1.1 retry runs only after an LFS HTTP 413 failure. Changes to .gitattributes and .gitignore remain ordinary repository changes that must be committed and pushed.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-5",
|
||||
title: "Version 2026.8.5",
|
||||
summary: "This release integrates Git LFS directly into Gitty and makes the staging area much easier to navigate when many files have changed.",
|
||||
steps: [
|
||||
"Git LFS is available directly from the Sync menu. Gitty checks the available extension, repository configuration, and pre-push hook, and reports whether Git LFS is bundled with Gitty or installed system-wide.",
|
||||
"LFS patterns can be added, marked as Lockable, and removed again. The dialog also lists LFS files in the current checkout, downloads missing objects, and prunes unused cache objects.",
|
||||
"After a successful clone or pull, Gitty automatically detects LFS repositories and downloads the required LFS objects with the same remote and credentials. Fresh clones also activate LFS locally, so a second manual pull is no longer required.",
|
||||
"Unstaged and Staged now sit side by side at equal width, scroll independently, and use clear arrows for Stage and Unstage. Narrow windows automatically fall back to a vertical layout.",
|
||||
"The centered List/Tree switch presents changes either as a compact list or groups them into collapsible folders in both areas.",
|
||||
"The redesigned file and folder context menu can stage, unstage, or save only that file or the folder's complete set of changes in a dedicated stash. In Changes and the File Explorer, new and untracked items can be added to .gitignore as an exact file, a complete folder, or an extension-wide pattern; the folder option only appears for folder selections. Tracked files and folders can be removed from the Git index with Stop tracking while remaining on disk. The selected name, parent path, and affected file count are now clearly separated.",
|
||||
"Repositories can be opened directly at startup with --repo PATH or --repo=PATH. With clone REMOTE TARGET, --clone REMOTE TARGET, or --clone=REMOTE TARGET, Gitty clones a remote into the exact local folder and opens it afterward. Relative paths are resolved against the current working directory, and requests are forwarded to an already-running Gitty instance.",
|
||||
"Square controls and surfaces make the interface more consistent while circular status markers, avatars, and characteristic branch shapes remain intact.",
|
||||
],
|
||||
note: "Changes to .gitattributes created by Git LFS belong to the repository and must be committed like any other change. New tracking patterns do not rewrite existing Git history retroactively.",
|
||||
},
|
||||
{
|
||||
id: "changelog-2026-8-4",
|
||||
title: "Version 2026.8.4",
|
||||
@@ -1703,6 +1886,7 @@
|
||||
{:else if category.id === "basics"}<GitCommitHorizontal size={17} aria-hidden="true" />
|
||||
{:else if category.id === "branches"}<GitBranch size={17} aria-hidden="true" />
|
||||
{:else if category.id === "remote"}<Cloud size={17} aria-hidden="true" />
|
||||
{:else if category.id === "lfs"}<Box size={17} aria-hidden="true" />
|
||||
{:else if category.id === "troubleshooting"}<Wrench size={17} aria-hidden="true" />
|
||||
{:else if category.id === "workflows"}<ListChecks size={17} aria-hidden="true" />
|
||||
{:else if category.id === "reference"}<Library size={17} aria-hidden="true" />
|
||||
@@ -1715,7 +1899,7 @@
|
||||
|
||||
<div class="help-nav-tip">
|
||||
<span class="help-tip-icon"><Lightbulb size={16} aria-hidden="true" /></span>
|
||||
<span>{isGerman ? "Suche auch nach Befehlen wie" : "Try commands such as"} <code>rebase</code>, <code>stash</code> {isGerman ? "oder" : "or"} <code>reflog</code>.</span>
|
||||
<span>{isGerman ? "Suche auch nach Befehlen wie" : "Try commands such as"} <code>rebase</code>, <code>lfs</code> {isGerman ? "oder" : "or"} <code>reflog</code>.</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
let customVisibleBranches = $state<Set<string>>(new Set());
|
||||
let loadedVisibilityRepository = $state("");
|
||||
let branchDialogOpen = $state(false);
|
||||
let localBranchGroupOpen = $state(true);
|
||||
let remoteBranchGroupOpen = $state(false);
|
||||
let expandedRefsCommitHash = $state("");
|
||||
let panelElement = $state<HTMLElement | null>(null);
|
||||
let contextCommit = $state<GitCommit | null>(null);
|
||||
@@ -428,6 +430,8 @@
|
||||
}
|
||||
|
||||
function openBranchDialog() {
|
||||
localBranchGroupOpen = true;
|
||||
remoteBranchGroupOpen = false;
|
||||
branchDialogOpen = true;
|
||||
}
|
||||
|
||||
@@ -1202,32 +1206,62 @@
|
||||
|
||||
<div class="branch-filter-dialog-list">
|
||||
{#if localBranchNames.length > 0}
|
||||
<span class="branch-filter-group-label">Local</span>
|
||||
{#each localBranchNames as branch}
|
||||
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={branchIsVisible(branch)}
|
||||
onchange={() => toggleGraphBranch(branch)}
|
||||
/>
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
<span>{branch}</span>
|
||||
</label>
|
||||
{/each}
|
||||
<section class="branch-filter-group">
|
||||
<button
|
||||
class="branch-filter-group-toggle"
|
||||
type="button"
|
||||
aria-expanded={localBranchGroupOpen}
|
||||
onclick={() => { localBranchGroupOpen = !localBranchGroupOpen; }}
|
||||
>
|
||||
{#if localBranchGroupOpen}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
|
||||
<span>Local</span>
|
||||
<em>{localBranchNames.filter(branchIsVisible).length}/{localBranchNames.length}</em>
|
||||
</button>
|
||||
{#if localBranchGroupOpen}
|
||||
<div class="branch-filter-group-list">
|
||||
{#each localBranchNames as branch}
|
||||
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={branchIsVisible(branch)}
|
||||
onchange={() => toggleGraphBranch(branch)}
|
||||
/>
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
<span>{branch}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{#if remoteBranchNames.length > 0}
|
||||
<span class="branch-filter-group-label">Remote</span>
|
||||
{#each remoteBranchNames as branch}
|
||||
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option remote" title={branch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={branchIsVisible(branch)}
|
||||
onchange={() => toggleGraphBranch(branch)}
|
||||
/>
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
<span>{branch}</span>
|
||||
</label>
|
||||
{/each}
|
||||
<section class="branch-filter-group">
|
||||
<button
|
||||
class="branch-filter-group-toggle"
|
||||
type="button"
|
||||
aria-expanded={remoteBranchGroupOpen}
|
||||
onclick={() => { remoteBranchGroupOpen = !remoteBranchGroupOpen; }}
|
||||
>
|
||||
{#if remoteBranchGroupOpen}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
|
||||
<span>Remote</span>
|
||||
<em>{remoteBranchNames.filter(branchIsVisible).length}/{remoteBranchNames.length}</em>
|
||||
</button>
|
||||
{#if remoteBranchGroupOpen}
|
||||
<div class="branch-filter-group-list">
|
||||
{#each remoteBranchNames as branch}
|
||||
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={branchIsVisible(branch)}
|
||||
onchange={() => toggleGraphBranch(branch)}
|
||||
/>
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
<span>{branch}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -277,7 +277,7 @@
|
||||
<span class="eyebrow">{scopeLabel}</span>
|
||||
<p class="dialog-title" title={displayPath}>{displayPath}</p>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
<div class="dialog-header-actions line-patch-header-actions">
|
||||
<div class="tool-surface-choice" aria-label={isGerman ? "Diff öffnen mit" : "Open diff with"}>
|
||||
<span>{isGerman ? "Öffnen mit" : "Open with"}</span>
|
||||
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty anzeigen" : "Show in Gitty"}>
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Check, FileDiff, RotateCcw, Undo2 } from "@lucide/svelte";
|
||||
import {
|
||||
Archive,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
FileDiff,
|
||||
FileMinus2,
|
||||
FileType,
|
||||
FileX,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderMinus,
|
||||
FolderTree,
|
||||
FolderX,
|
||||
RotateCcw,
|
||||
} from "@lucide/svelte";
|
||||
import iconUrl from "../../../src-tauri/icons/icon.png";
|
||||
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
|
||||
import type { FileStatusKind, GitFileStatus, GitIgnoreKind, GitStatus } from "../types";
|
||||
|
||||
interface Props {
|
||||
changedFiles: GitFileStatus[];
|
||||
@@ -17,11 +31,43 @@
|
||||
onUnstage: (files: GitFileStatus[]) => void;
|
||||
onDiscard: (files: GitFileStatus[], staged: boolean) => void;
|
||||
onDiscardMany: (files: GitFileStatus[]) => void;
|
||||
onStash: (files: GitFileStatus[], label: string) => void;
|
||||
onIgnore: (target: string, kind: GitIgnoreKind) => void;
|
||||
onStopTracking: (target: string, kind: "file" | "folder") => void;
|
||||
onPatch: (file: GitFileStatus, staged: boolean) => void;
|
||||
onStageAll: () => void;
|
||||
onUnstageAll: () => void;
|
||||
}
|
||||
|
||||
type StatusViewMode = "list" | "tree";
|
||||
type StatusLaneKind = "unstaged" | "staged";
|
||||
|
||||
interface StatusFolderNode {
|
||||
kind: "folder";
|
||||
name: string;
|
||||
path: string;
|
||||
depth: number;
|
||||
fileCount: number;
|
||||
children: StatusTreeNode[];
|
||||
}
|
||||
|
||||
interface StatusFileNode {
|
||||
kind: "file";
|
||||
name: string;
|
||||
path: string;
|
||||
depth: number;
|
||||
file: GitFileStatus;
|
||||
}
|
||||
|
||||
type StatusTreeNode = StatusFolderNode | StatusFileNode;
|
||||
|
||||
interface StatusContextTarget {
|
||||
lane: StatusLaneKind;
|
||||
kind: "file" | "folder";
|
||||
label: string;
|
||||
files: GitFileStatus[];
|
||||
}
|
||||
|
||||
let {
|
||||
changedFiles = [],
|
||||
stagedCount = 0,
|
||||
@@ -36,6 +82,9 @@
|
||||
onUnstage = () => {},
|
||||
onDiscard = () => {},
|
||||
onDiscardMany = () => {},
|
||||
onStash = () => {},
|
||||
onIgnore = () => {},
|
||||
onStopTracking = () => {},
|
||||
onPatch = () => {},
|
||||
onStageAll = () => {},
|
||||
onUnstageAll = () => {},
|
||||
@@ -65,8 +114,202 @@
|
||||
return `${file.old_path ?? ""}:${file.path}`;
|
||||
}
|
||||
|
||||
function sortStatusTree(nodes: StatusTreeNode[]) {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
for (const node of nodes) {
|
||||
if (node.kind === "folder") sortStatusTree(node.children);
|
||||
}
|
||||
}
|
||||
|
||||
function buildStatusTree(files: GitFileStatus[]): StatusTreeNode[] {
|
||||
const roots: StatusTreeNode[] = [];
|
||||
const folders = new Map<string, StatusFolderNode>();
|
||||
|
||||
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
||||
const parts = file.path.split(/[\\/]+/).filter(Boolean);
|
||||
if (parts.length === 0) continue;
|
||||
|
||||
let currentPath = "";
|
||||
let siblings = roots;
|
||||
const ancestors: StatusFolderNode[] = [];
|
||||
|
||||
for (let index = 0; index < parts.length - 1; index++) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[index]}` : parts[index];
|
||||
let folder = folders.get(currentPath);
|
||||
if (!folder) {
|
||||
folder = {
|
||||
kind: "folder",
|
||||
name: parts[index],
|
||||
path: currentPath,
|
||||
depth: index,
|
||||
fileCount: 0,
|
||||
children: [],
|
||||
};
|
||||
folders.set(currentPath, folder);
|
||||
siblings.push(folder);
|
||||
}
|
||||
ancestors.push(folder);
|
||||
siblings = folder.children;
|
||||
}
|
||||
|
||||
siblings.push({
|
||||
kind: "file",
|
||||
name: parts[parts.length - 1] ?? file.path,
|
||||
path: file.path,
|
||||
depth: Math.max(parts.length - 1, 0),
|
||||
file,
|
||||
});
|
||||
for (const folder of ancestors) folder.fileCount += 1;
|
||||
}
|
||||
|
||||
sortStatusTree(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
function flattenStatusTree(nodes: StatusTreeNode[], lane: StatusLaneKind): StatusTreeNode[] {
|
||||
const visible: StatusTreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
visible.push(node);
|
||||
if (node.kind === "folder" && !collapsedStatusFolders.has(`${lane}:${node.path}`)) {
|
||||
visible.push(...flattenStatusTree(node.children, lane));
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
function listStatusRows(files: GitFileStatus[]): StatusFileNode[] {
|
||||
return files.map((file) => ({
|
||||
kind: "file",
|
||||
name: fileName(file),
|
||||
path: file.path,
|
||||
depth: 0,
|
||||
file,
|
||||
}));
|
||||
}
|
||||
|
||||
let selectedStatusPaths = $state<Set<string>>(new Set());
|
||||
let selectionAnchorKey = $state("");
|
||||
let statusView = $state<StatusViewMode>("list");
|
||||
let collapsedStatusFolders = $state<Set<string>>(new Set());
|
||||
let statusContextTarget = $state<StatusContextTarget | null>(null);
|
||||
let statusContextMenuX = $state(0);
|
||||
let statusContextMenuY = $state(0);
|
||||
let statusContextMenuElement = $state<HTMLDivElement | null>(null);
|
||||
|
||||
function toggleStatusFolder(lane: StatusLaneKind, path: string) {
|
||||
const next = new Set(collapsedStatusFolders);
|
||||
const key = `${lane}:${path}`;
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
collapsedStatusFolders = next;
|
||||
}
|
||||
|
||||
function filesInStatusFolder(lane: StatusLaneKind, path: string): GitFileStatus[] {
|
||||
const prefix = `${path.replace(/\\/g, "/").replace(/\/$/, "")}/`;
|
||||
const files = lane === "unstaged" ? unstagedFiles : stagedFiles;
|
||||
return files.filter((file) => {
|
||||
const currentPath = file.path.replace(/\\/g, "/");
|
||||
const oldPath = file.old_path?.replace(/\\/g, "/") ?? "";
|
||||
return currentPath.startsWith(prefix) || oldPath.startsWith(prefix);
|
||||
});
|
||||
}
|
||||
|
||||
function openStatusContextMenu(
|
||||
event: MouseEvent,
|
||||
lane: StatusLaneKind,
|
||||
kind: "file" | "folder",
|
||||
label: string,
|
||||
files: GitFileStatus[],
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (files.length === 0) return;
|
||||
statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - 288));
|
||||
statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - 174));
|
||||
statusContextTarget = { lane, kind, label, files };
|
||||
requestAnimationFrame(() => {
|
||||
if (!statusContextMenuElement) return;
|
||||
const bounds = statusContextMenuElement.getBoundingClientRect();
|
||||
statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - bounds.width - 8));
|
||||
statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - bounds.height - 8));
|
||||
});
|
||||
}
|
||||
|
||||
function statusContextName(label: string): string {
|
||||
const normalized = label.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
return normalized.split("/").pop() || label;
|
||||
}
|
||||
|
||||
function statusContextParent(label: string): string {
|
||||
const normalized = label.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const separator = normalized.lastIndexOf("/");
|
||||
return separator > 0 ? normalized.slice(0, separator) : "Repository root";
|
||||
}
|
||||
|
||||
function closeStatusContextMenu() {
|
||||
statusContextTarget = null;
|
||||
}
|
||||
|
||||
function runStatusContextStageAction() {
|
||||
const target = statusContextTarget;
|
||||
if (!target) return;
|
||||
closeStatusContextMenu();
|
||||
if (target.lane === "unstaged") onStage(target.files);
|
||||
else onUnstage(target.files);
|
||||
}
|
||||
|
||||
function runStatusContextStashAction() {
|
||||
const target = statusContextTarget;
|
||||
if (!target) return;
|
||||
closeStatusContextMenu();
|
||||
onStash(target.files, target.label);
|
||||
}
|
||||
|
||||
function isIgnoreableNewFile(file: GitFileStatus): boolean {
|
||||
const path = file.path.replace(/\\/g, "/").toLowerCase();
|
||||
if (path === ".gitignore") return false;
|
||||
return file.unstaged === "untracked" || (file.staged === "added" && file.old_path === null);
|
||||
}
|
||||
|
||||
function isTrackedStatusFile(file: GitFileStatus): boolean {
|
||||
return file.unstaged !== "untracked" && file.staged !== "deleted";
|
||||
}
|
||||
|
||||
function statusContextExtension(label: string): string {
|
||||
const name = statusContextName(label);
|
||||
const separator = name.lastIndexOf(".");
|
||||
return separator > 0 && separator < name.length - 1 ? name.slice(separator + 1) : "";
|
||||
}
|
||||
|
||||
function statusContextFolder(target: StatusContextTarget): string {
|
||||
if (target.kind === "folder") return target.label.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalized = target.label.replace(/\\/g, "/");
|
||||
const separator = normalized.lastIndexOf("/");
|
||||
return separator > 0 ? normalized.slice(0, separator) : "";
|
||||
}
|
||||
|
||||
function runStatusContextIgnoreAction(kind: GitIgnoreKind) {
|
||||
const target = statusContextTarget;
|
||||
if (!target) return;
|
||||
const ignoreTarget = kind === "folder" ? statusContextFolder(target) : target.label;
|
||||
if (!ignoreTarget) return;
|
||||
closeStatusContextMenu();
|
||||
onIgnore(ignoreTarget, kind);
|
||||
}
|
||||
|
||||
function runStatusContextStopTracking() {
|
||||
const target = statusContextTarget;
|
||||
if (!target) return;
|
||||
closeStatusContextMenu();
|
||||
onStopTracking(target.label, target.kind);
|
||||
}
|
||||
|
||||
function handleStatusWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") closeStatusContextMenu();
|
||||
}
|
||||
|
||||
function isStatusSelected(file: GitFileStatus): boolean {
|
||||
return selectedStatusPaths.has(fileKey(file));
|
||||
@@ -146,8 +389,16 @@
|
||||
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
||||
let unstagedFiles = $derived(changedFiles.filter((file) => file.unstaged !== null));
|
||||
let stagedFiles = $derived(changedFiles.filter((file) => file.staged !== null));
|
||||
let unstagedTree = $derived(buildStatusTree(unstagedFiles));
|
||||
let stagedTree = $derived(buildStatusTree(stagedFiles));
|
||||
let visibleUnstagedRows = $derived(statusView === "tree" ? flattenStatusTree(unstagedTree, "unstaged") : listStatusRows(unstagedFiles));
|
||||
let visibleStagedRows = $derived(statusView === "tree" ? flattenStatusTree(stagedTree, "staged") : listStatusRows(stagedFiles));
|
||||
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
|
||||
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
|
||||
let statusContextCanIgnore = $derived(statusContextTarget?.files.some(isIgnoreableNewFile) ?? false);
|
||||
let statusContextCanStopTracking = $derived(statusContextTarget?.files.some(isTrackedStatusFile) ?? false);
|
||||
let statusContextIgnoreExtension = $derived(statusContextTarget?.kind === "file" ? statusContextExtension(statusContextTarget.label) : "");
|
||||
let statusContextIgnoreFolder = $derived(statusContextTarget ? statusContextFolder(statusContextTarget) : "");
|
||||
|
||||
$effect(() => {
|
||||
const validKeys = new Set(changedFiles.map(fileKey));
|
||||
@@ -157,12 +408,22 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={closeStatusContextMenu} onkeydown={handleStatusWindowKeydown} />
|
||||
|
||||
<section class="panel status-panel relative grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Working tree status">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">Workspace</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
|
||||
</div>
|
||||
<div class="status-view-switch" role="group" aria-label="Changes view">
|
||||
<button class:active={statusView === "list"} type="button" onclick={() => statusView = "list"} title="List view" aria-label="List view" aria-pressed={statusView === "list"}>
|
||||
<i class="status-view-icon list-icon" aria-hidden="true"></i><span>List</span>
|
||||
</button>
|
||||
<button class:active={statusView === "tree"} type="button" onclick={() => statusView = "tree"} title="Tree view" aria-label="Tree view" aria-pressed={statusView === "tree"}>
|
||||
<FolderTree size={13} aria-hidden="true" /><span>Tree</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="status-head-actions">
|
||||
<span class="pill pill-count">{stagedCount} staged</span>
|
||||
<span class="pill pill-count">{unstagedCount} unstaged</span>
|
||||
@@ -181,19 +442,22 @@
|
||||
{:else if changedFiles.length === 0}
|
||||
<div class="blank-state">No file changes returned.</div>
|
||||
{:else}
|
||||
<div class="status-lanes overflow-auto">
|
||||
<section class="status-lane" aria-label="Unstaged changes">
|
||||
<div class="status-lanes">
|
||||
<section class="status-lane unstaged-lane" aria-label="Unstaged changes">
|
||||
<header class="status-lane-head">
|
||||
<div class="status-lane-title"><strong>Unstaged</strong><span>{unstagedCount}</span></div>
|
||||
<div class="status-lane-title">
|
||||
<div class="status-lane-copy"><strong>Unstaged</strong><small>Working tree</small></div>
|
||||
<span class="status-lane-count">{unstagedCount}</span>
|
||||
</div>
|
||||
<div class="status-lane-actions">
|
||||
{#if selectedUnstagedCount > 1}
|
||||
<span class="status-selection-count">{selectedUnstagedCount} selected</span>
|
||||
<button class="btn-sm" type="button" onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))} disabled={isBusy} title={`Stage ${selectedUnstagedCount} selected files`}>
|
||||
<Check size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
|
||||
<ArrowRight size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title="Stage all unstaged files">
|
||||
<Check size={13} aria-hidden="true" /> Stage all
|
||||
<ArrowRight size={13} aria-hidden="true" /> Stage all
|
||||
</button>
|
||||
{#if selectedUnstagedCount > 1}
|
||||
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null), false)} disabled={isBusy} title={`Discard unstaged changes in ${selectedUnstagedCount} selected files`}>
|
||||
@@ -203,37 +467,54 @@
|
||||
</div>
|
||||
</header>
|
||||
<div class="status-file-list">
|
||||
{#each unstagedFiles as file (`unstaged:${fileKey(file)}`)}
|
||||
{@const stageTargets = selectedStageTargets(file)}
|
||||
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
|
||||
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||
<strong>{fileName(file)}</strong>
|
||||
<span>{displayPath(file)}</span>
|
||||
{#each visibleUnstagedRows as row (`unstaged:${row.kind}:${row.kind === "file" ? fileKey(row.file) : row.path}`)}
|
||||
{#if row.kind === "folder"}
|
||||
<button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("unstaged", row.path)} oncontextmenu={(event) => openStatusContextMenu(event, "unstaged", "folder", row.path, filesInStatusFolder("unstaged", row.path))} title={row.path} aria-expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)}>
|
||||
<i class:expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)} class="status-tree-chevron" aria-hidden="true"></i>
|
||||
{#if collapsedStatusFolders.has(`unstaged:${row.path}`)}<Folder class="status-folder-lucide" size={15} aria-hidden="true" />{:else}<FolderOpen class="status-folder-lucide" size={15} aria-hidden="true" />{/if}
|
||||
<strong>{row.name}</strong>
|
||||
<span class="status-folder-count">{row.fileCount}</span>
|
||||
</button>
|
||||
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
||||
<div class="status-file-actions">
|
||||
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><Check size={14} aria-hidden="true" /></button>
|
||||
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Show unstaged details"><FileDiff size={14} aria-hidden="true" /></button>
|
||||
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
||||
</div>
|
||||
</article>
|
||||
{:else}
|
||||
{@const file = row.file}
|
||||
{@const stageTargets = selectedStageTargets(file)}
|
||||
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "unstaged", "file", file.path, [file])}>
|
||||
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||
<strong>{fileName(file)}</strong>
|
||||
<span>{displayPath(file)}</span>
|
||||
</button>
|
||||
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
||||
<div class="status-file-actions">
|
||||
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><ArrowRight size={14} aria-hidden="true" /></button>
|
||||
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Show unstaged details"><FileDiff size={14} aria-hidden="true" /></button>
|
||||
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="status-lane" aria-label="Staged changes">
|
||||
<div class="status-flow-divider" aria-hidden="true">
|
||||
<span><ArrowRight size={12} /></span>
|
||||
</div>
|
||||
|
||||
<section class="status-lane staged-lane" aria-label="Staged changes">
|
||||
<header class="status-lane-head">
|
||||
<div class="status-lane-title"><strong>Staged</strong><span>{stagedCount}</span></div>
|
||||
<div class="status-lane-title">
|
||||
<div class="status-lane-copy"><strong>Staged</strong><small>Next commit</small></div>
|
||||
<span class="status-lane-count">{stagedCount}</span>
|
||||
</div>
|
||||
<div class="status-lane-actions">
|
||||
{#if selectedStagedCount > 1}
|
||||
<span class="status-selection-count">{selectedStagedCount} selected</span>
|
||||
<button class="btn-sm" type="button" onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))} disabled={isBusy} title={`Unstage ${selectedStagedCount} selected files`}>
|
||||
<Undo2 size={13} aria-hidden="true" /> Unstage {selectedStagedCount}
|
||||
<ArrowLeft size={13} aria-hidden="true" /> Unstage {selectedStagedCount}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title="Unstage all staged files">
|
||||
<Undo2 size={13} aria-hidden="true" /> Unstage all
|
||||
<ArrowLeft size={13} aria-hidden="true" /> Unstage all
|
||||
</button>
|
||||
{#if selectedStagedCount > 1}
|
||||
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null), true)} disabled={isBusy} title={`Discard staged changes in ${selectedStagedCount} selected files`}>
|
||||
@@ -243,20 +524,30 @@
|
||||
</div>
|
||||
</header>
|
||||
<div class="status-file-list">
|
||||
{#each stagedFiles as file (`staged:${fileKey(file)}`)}
|
||||
{@const unstageTargets = selectedUnstageTargets(file)}
|
||||
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
|
||||
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||
<strong>{fileName(file)}</strong>
|
||||
<span>{displayPath(file)}</span>
|
||||
{#each visibleStagedRows as row (`staged:${row.kind}:${row.kind === "file" ? fileKey(row.file) : row.path}`)}
|
||||
{#if row.kind === "folder"}
|
||||
<button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("staged", row.path)} oncontextmenu={(event) => openStatusContextMenu(event, "staged", "folder", row.path, filesInStatusFolder("staged", row.path))} title={row.path} aria-expanded={!collapsedStatusFolders.has(`staged:${row.path}`)}>
|
||||
<i class:expanded={!collapsedStatusFolders.has(`staged:${row.path}`)} class="status-tree-chevron" aria-hidden="true"></i>
|
||||
{#if collapsedStatusFolders.has(`staged:${row.path}`)}<Folder class="status-folder-lucide" size={15} aria-hidden="true" />{:else}<FolderOpen class="status-folder-lucide" size={15} aria-hidden="true" />{/if}
|
||||
<strong>{row.name}</strong>
|
||||
<span class="status-folder-count">{row.fileCount}</span>
|
||||
</button>
|
||||
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
|
||||
<div class="status-file-actions">
|
||||
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Unstage file"><Undo2 size={14} aria-hidden="true" /></button>
|
||||
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Show staged details"><FileDiff size={14} aria-hidden="true" /></button>
|
||||
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Discard staged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
||||
</div>
|
||||
</article>
|
||||
{:else}
|
||||
{@const file = row.file}
|
||||
{@const unstageTargets = selectedUnstageTargets(file)}
|
||||
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "staged", "file", file.path, [file])}>
|
||||
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||
<strong>{fileName(file)}</strong>
|
||||
<span>{displayPath(file)}</span>
|
||||
</button>
|
||||
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
|
||||
<div class="status-file-actions">
|
||||
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Unstage file"><ArrowLeft size={14} aria-hidden="true" /></button>
|
||||
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Show staged details"><FileDiff size={14} aria-hidden="true" /></button>
|
||||
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Discard staged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if}
|
||||
</div>
|
||||
@@ -284,6 +575,83 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if statusContextTarget}
|
||||
<div bind:this={statusContextMenuElement} class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={`Actions for ${statusContextTarget.label}`} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
|
||||
<div class="status-context-label">
|
||||
<span class="status-context-object-icon" aria-hidden="true">
|
||||
{#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else}<FileDiff size={16} />{/if}
|
||||
</span>
|
||||
<span class="status-context-object-copy">
|
||||
<span class="status-context-kind">{statusContextTarget.lane === "unstaged" ? "Unstaged" : "Staged"} {statusContextTarget.kind}</span>
|
||||
<strong title={statusContextTarget.label}>{statusContextName(statusContextTarget.label)}</strong>
|
||||
<span class="status-context-path" title={statusContextTarget.label}><Folder size={10} aria-hidden="true" />{statusContextParent(statusContextTarget.label)}</span>
|
||||
</span>
|
||||
<span class="status-context-count" title={`${statusContextTarget.files.length} ${statusContextTarget.files.length === 1 ? "file" : "files"}`}>
|
||||
{statusContextTarget.files.length}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" role="menuitem" onclick={runStatusContextStageAction} disabled={isBusy}>
|
||||
<span class="status-context-action-icon" aria-hidden="true">
|
||||
{#if statusContextTarget.lane === "unstaged"}<ArrowRight size={15} />{:else}<ArrowLeft size={15} />{/if}
|
||||
</span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>{statusContextTarget.lane === "unstaged" ? "Stage" : "Unstage"} {statusContextTarget.kind}</strong>
|
||||
<span>{statusContextTarget.lane === "unstaged" ? "Add to the next commit" : "Move back to working changes"}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={runStatusContextStashAction} disabled={isBusy}>
|
||||
<span class="status-context-action-icon" aria-hidden="true"><Archive size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Stash {statusContextTarget.kind}</strong>
|
||||
<span>Save {statusContextTarget.files.length === 1 ? "this file" : `${statusContextTarget.files.length} files`} for later</span>
|
||||
</span>
|
||||
</button>
|
||||
{#if statusContextCanIgnore || statusContextCanStopTracking}
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
{/if}
|
||||
{#if statusContextCanStopTracking}
|
||||
<button type="button" role="menuitem" onclick={runStatusContextStopTracking} disabled={isBusy} title="Keep the working-tree content and remove it from the Git index">
|
||||
<span class="status-context-action-icon untrack" aria-hidden="true">
|
||||
{#if statusContextTarget.kind === "folder"}<FolderMinus size={15} />{:else}<FileMinus2 size={15} />{/if}
|
||||
</span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Stop tracking {statusContextTarget.kind}</strong>
|
||||
<span>Keep it on disk and remove it from Git</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if statusContextCanIgnore}
|
||||
{#if statusContextTarget.kind === "file"}
|
||||
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("file")} disabled={isBusy} title={`Add /${statusContextTarget.label.replace(/\\/g, "/")} to .gitignore`}>
|
||||
<span class="status-context-action-icon ignore" aria-hidden="true"><FileX size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Ignore file</strong>
|
||||
<span>Add only this file to .gitignore</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if statusContextIgnoreExtension}
|
||||
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("extension")} disabled={isBusy} title={`Add *.${statusContextIgnoreExtension} to .gitignore`}>
|
||||
<span class="status-context-action-icon ignore" aria-hidden="true"><FileType size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Ignore all *.{statusContextIgnoreExtension} files</strong>
|
||||
<span>Match this file type repository-wide</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if statusContextTarget.kind === "folder" && statusContextIgnoreFolder}
|
||||
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("folder")} disabled={isBusy} title={`Add /${statusContextIgnoreFolder}/ to .gitignore`}>
|
||||
<span class="status-context-action-icon ignore" aria-hidden="true"><FolderX size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Ignore folder</strong>
|
||||
<span>Add this folder and its contents to .gitignore</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.status-panel-overlay {
|
||||
position: absolute;
|
||||
@@ -292,8 +660,12 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background:
|
||||
radial-gradient(circle at 50% 38%, rgba(111, 140, 255, 0.1), transparent 55%),
|
||||
rgba(7, 10, 16, 0.62);
|
||||
radial-gradient(
|
||||
circle at 50% 38%,
|
||||
color-mix(in srgb, var(--color-accent) 13%, transparent),
|
||||
transparent 55%
|
||||
),
|
||||
var(--app-dialog-backdrop);
|
||||
backdrop-filter: blur(4px);
|
||||
animation: status-panel-overlay-in 120ms ease;
|
||||
}
|
||||
@@ -305,13 +677,13 @@
|
||||
gap: 12px;
|
||||
width: min(300px, calc(100% - 32px));
|
||||
padding: 26px 28px 26px;
|
||||
border: 1px solid rgba(90, 111, 154, 0.28);
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(23, 29, 43, 0.92), rgba(12, 17, 27, 0.94)),
|
||||
var(--color-surface-raised);
|
||||
var(--app-panel-highlight),
|
||||
var(--app-dialog-bg);
|
||||
color: var(--color-ink);
|
||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
box-shadow: var(--app-dialog-shadow);
|
||||
}
|
||||
|
||||
.status-panel-overlay-mark {
|
||||
@@ -326,7 +698,7 @@
|
||||
.status-panel-overlay-halo {
|
||||
position: absolute;
|
||||
inset: 6px;
|
||||
border: 1px solid rgba(111, 140, 255, 0.22);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 34%, transparent);
|
||||
border-radius: 22px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
@@ -335,7 +707,7 @@
|
||||
}
|
||||
.status-panel-overlay-halo.halo-two {
|
||||
inset: 16px;
|
||||
border-color: rgba(77, 182, 214, 0.24);
|
||||
border-color: color-mix(in srgb, var(--color-accent) 38%, transparent);
|
||||
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite reverse;
|
||||
}
|
||||
|
||||
@@ -353,16 +725,16 @@
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 165;
|
||||
stroke-dashoffset: 165;
|
||||
filter: drop-shadow(0 0 6px rgba(77, 182, 214, 0.32));
|
||||
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--color-accent) 38%, transparent));
|
||||
animation: status-panel-overlay-trace-draw 2.6s ease-in-out infinite;
|
||||
}
|
||||
.status-panel-overlay-traces .trace-main { stroke: #6f8cff; }
|
||||
.status-panel-overlay-traces .trace-main { stroke: var(--color-primary); }
|
||||
.status-panel-overlay-traces .trace-branch {
|
||||
stroke: #4db6d6;
|
||||
stroke: var(--color-accent);
|
||||
animation-delay: 0.28s;
|
||||
}
|
||||
.status-panel-overlay-traces .trace-cut {
|
||||
stroke: rgba(177, 186, 208, 0.42);
|
||||
stroke: color-mix(in srgb, var(--color-ink-muted) 52%, transparent);
|
||||
stroke-dasharray: 128;
|
||||
stroke-dashoffset: 128;
|
||||
animation-delay: 0.55s;
|
||||
@@ -375,8 +747,8 @@
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
filter:
|
||||
drop-shadow(0 8px 12px rgba(0, 0, 0, 0.5))
|
||||
drop-shadow(0 0 10px rgba(77, 182, 214, 0.2));
|
||||
drop-shadow(0 8px 12px color-mix(in srgb, var(--color-ink) 24%, transparent))
|
||||
drop-shadow(0 0 10px color-mix(in srgb, var(--color-accent) 24%, transparent));
|
||||
animation: status-panel-overlay-icon-float 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -397,15 +769,16 @@
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(111, 140, 255, 0.14);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface-dim));
|
||||
}
|
||||
.status-panel-overlay-bar span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 46%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, transparent, #6f8cff 40%, #4db6d6 74%, transparent);
|
||||
box-shadow: 0 0 12px rgba(77, 182, 214, 0.26);
|
||||
background: linear-gradient(90deg, transparent, var(--color-primary) 40%, var(--color-accent) 74%, transparent);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--color-accent) 32%, transparent);
|
||||
animation: status-panel-overlay-bar-slide 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
GitCommitComparison,
|
||||
GitIgnoreKind,
|
||||
GitLfsStatus,
|
||||
GitRepositoryFile,
|
||||
GitRemote,
|
||||
MergeStrategy,
|
||||
@@ -90,6 +92,35 @@ export function getStatus(path: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("get_status", { path });
|
||||
}
|
||||
|
||||
export function getGitLfsStatus(path: string): Promise<GitLfsStatus> {
|
||||
return invoke<GitLfsStatus>("git_lfs_status", { path });
|
||||
}
|
||||
|
||||
export function installGitLfs(path: string): Promise<GitLfsStatus> {
|
||||
return invoke<GitLfsStatus>("git_lfs_install", { path });
|
||||
}
|
||||
|
||||
export function trackGitLfsPattern(path: string, pattern: string, lockable = false): Promise<GitLfsStatus> {
|
||||
return invoke<GitLfsStatus>("git_lfs_track", { path, pattern, lockable });
|
||||
}
|
||||
|
||||
export function untrackGitLfsPattern(path: string, pattern: string): Promise<GitLfsStatus> {
|
||||
return invoke<GitLfsStatus>("git_lfs_untrack", { path, pattern });
|
||||
}
|
||||
|
||||
export function pullGitLfsObjects(path: string, remote?: string, username?: string, password?: string): Promise<GitLfsStatus> {
|
||||
return invoke<GitLfsStatus>("git_lfs_pull", {
|
||||
path,
|
||||
remote: remote || null,
|
||||
username: username ?? null,
|
||||
password: password ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function pruneGitLfsObjects(path: string): Promise<GitLfsStatus> {
|
||||
return invoke<GitLfsStatus>("git_lfs_prune", { path });
|
||||
}
|
||||
|
||||
// Sets the taskbar icon badge to ahead + behind + changed status files (0 clears it). Windows only — a no-op on
|
||||
// other platforms, since Windows has no native numeric badge to fall back to.
|
||||
export function setSyncBadge(ahead: number, behind: number, changes: number): Promise<void> {
|
||||
@@ -261,6 +292,14 @@ export function unstageFiles(path: string, files: string[]): Promise<GitStatus>
|
||||
return invoke<GitStatus>("unstage_files", { path, files });
|
||||
}
|
||||
|
||||
export function addToGitignore(path: string, target: string, kind: GitIgnoreKind): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("add_to_gitignore", { path, target, kind });
|
||||
}
|
||||
|
||||
export function untrackPaths(path: string, targets: string[]): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("untrack_paths", { path, targets });
|
||||
}
|
||||
|
||||
export function restoreFiles(
|
||||
path: string,
|
||||
files: string[],
|
||||
@@ -302,11 +341,13 @@ export function stashPush(
|
||||
path: string,
|
||||
message?: string,
|
||||
includeUntracked = true,
|
||||
paths?: string[],
|
||||
): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("stash_push", {
|
||||
path,
|
||||
message: message?.trim() ? message.trim() : null,
|
||||
includeUntracked,
|
||||
paths: paths?.length ? paths : null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,22 @@ export type FileStatusKind =
|
||||
| "conflicted"
|
||||
| "unknown";
|
||||
|
||||
export type GitIgnoreKind = "file" | "extension" | "folder";
|
||||
|
||||
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
|
||||
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
|
||||
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
|
||||
export type AppTheme = "system" | "light" | "dark";
|
||||
export type AppAppearance = "modern" | "classic" | "custom";
|
||||
export type AppLanguage = "en" | "de";
|
||||
|
||||
export interface CustomThemeColors {
|
||||
background: string;
|
||||
surface: string;
|
||||
accent: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface CommitAiStatus {
|
||||
phase: CommitAiPhase;
|
||||
model_id: string | null;
|
||||
@@ -193,6 +203,34 @@ export interface GitRepositoryFile {
|
||||
status: FileStatusKind | null;
|
||||
}
|
||||
|
||||
export interface GitLfsPattern {
|
||||
pattern: string;
|
||||
source: string;
|
||||
lockable: boolean;
|
||||
tracked: boolean;
|
||||
}
|
||||
|
||||
export interface GitLfsFile {
|
||||
name: string;
|
||||
size: number;
|
||||
checkout: boolean;
|
||||
downloaded: boolean;
|
||||
oid_type: string;
|
||||
oid: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface GitLfsStatus {
|
||||
available: boolean;
|
||||
bundled: boolean;
|
||||
version: string | null;
|
||||
filters_configured: boolean;
|
||||
hook_installed: boolean;
|
||||
repository_uses_lfs: boolean;
|
||||
patterns: GitLfsPattern[];
|
||||
files: GitLfsFile[];
|
||||
}
|
||||
|
||||
export interface RepositoryBundle {
|
||||
status: GitStatus;
|
||||
branches: GitBranch[];
|
||||
@@ -200,6 +238,7 @@ export interface RepositoryBundle {
|
||||
stashes: GitStash[];
|
||||
commits: GitCommit[];
|
||||
files: GitRepositoryFile[];
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
export interface GitDiffFile {
|
||||
|
||||
Reference in New Issue
Block a user