Compare commits

..
30 Commits
Author SHA1 Message Date
Christoph 5f7899bcee Merge pull request 'Feature/clone repo' (#15) from feature/clone_repo into main
publish / publish-tauri (, windows-latest) (release) Successful in 26m10s
Reviewed-on: #15
2026-07-04 23:24:27 +00:00
Christoph b646a2c647 feat(clone): support credentialed git clone with askpass
Cloning now accepts optional username/password and passes them to the
Rust git layer via GIT_ASKPASS, avoiding interactive prompts. The UI
detects authentication failures, opens the credential dialog for clone,
and auto-hides error messages to keep the flow smooth.

- Add username/password support to clone_repository and git.ts
- Detect auth failures and route users to the clone credential dialog
- Improve clone UX with a dedicated loading overlay and timed errors
2026-07-05 01:18:26 +02:00
Christoph 32497d53df feat(clone): add repository cloning flow to UI and backend
This introduces a new Tauri command to clone a remote repository into a
validated destination folder, then return the full repository bundle for
immediate rendering. The Svelte app gains a clone dialog and a new action
in repository management, wiring the cloned bundle into existing refresh
helpers. Dialog backdrops were also adjusted to rely on explicit close
controls.

- Add clone_repository command and core cloning logic in Rust
- Create CloneRepositoryDialog and integrate it into App.svelte
- Improve clone-related styling and tighten dialog backdrop behavior
2026-07-05 01:05:23 +02:00
Christoph 6365e164aa update for linux 2026-07-04 18:14:25 +02:00
Christoph f6af156fbd install on linux 2026-07-04 17:49:08 +02:00
Christoph 33cef059e8 Update version to 2026.7.11 2026-07-04 13:56:50 +02:00
Christoph Brandau 9a4c6e5b9b feat(branches): add force delete flow for local branches
publish / publish-tauri (, windows-latest) (release) Successful in 22m57s
Branch deletion now supports an optional force mode in the Tauri
command, enabling deletion of branches that are not fully merged.
The UI replaces the simple confirm prompt with a dedicated dialog and
auto-escalates to force delete when Git rejects a normal delete.

- Add force flag to delete_branch command and tests
- Implement BranchDeleteConfirmDialog and wire it into App.svelte
- Update branch context menu actions and styling
2026-07-04 00:52:40 +02:00
Christoph c2d7fefb47 Merge pull request 'feat(git): add rebase workflow with UI state handling' (#14) from feature/rebase into master
Reviewed-on: #14
2026-07-03 22:36:42 +00:00
Christoph ede2e46d50 Merge pull request 'feat(git stash): add stash listing and push/apply/pop/drop UI' (#13) from feature/stash into master
Reviewed-on: #13
2026-07-03 22:36:24 +00:00
Christoph Brandau 3c4425a408 feat(git): add rebase workflow with UI state handling
This introduces Git rebase commands (start, continue, abort) and
exposes whether a rebase is currently in progress via the status
payload. The UI now blocks committing during an active rebase and
shows a dedicated rebase notice with continue/abort actions.

- Add tauri commands for rebase operations and status detection
- Update frontend to render rebase state and controls
- Adjust conflict/resolution messaging and related UI styling
2026-07-04 00:34:36 +02:00
Christoph Brandau 1d67312ee4 feat(git stash): add stash listing and push/apply/pop/drop UI
This change introduces Git stash support end-to-end, including a new
backend command to list stashes and operations to push, apply, pop, and
drop them. The frontend now fetches stashes as part of the repository
bundle and provides a dedicated panel to manage shelved changes.

- Add GitStash model and Tauri commands for stash operations
- Implement StashPanel component and wire it into the app
- Adjust sidebar layout and add stash-specific styling
2026-07-04 00:16:27 +02:00
Christoph 201bb90bf7 Merge pull request 'feat(history-ui): add resizable commit history aside' (#12) from feature/git_graph into master
Reviewed-on: #12
2026-07-03 21:39:58 +00:00
Christoph Brandau 9c371ec520 feat(history-ui): add resizable commit history aside
The commit history panel is now resizable with a draggable handle and
keyboard support. The chosen width is persisted in local storage so the
layout stays consistent across sessions. Git commit loading was also
adjusted to include all branch tips for a more complete graph.

- Add width persistence and pointer/keyboard resizing for history panel
- Update commit graph query to use topo-order across all refs
- Extend tests to ensure branch tips are included in commit results
2026-07-03 23:35:46 +02:00
Christoph Brandau 835bfae254 feat(history): enhance commit history UI with richer styling
The commit history panel now renders a more structured commit header,
including an avatar, kind badge, and clearer metadata layout. The git
graph visuals were also refined with smoother paths and improved hover
effects to make the history easier to scan.

- Add commit kind/root/merge badges and author initials
- Improve graph rendering with curved paths and hover emphasis
- Update CSS for commit cards, ref chips, and graph styling
2026-07-03 21:32:20 +02:00
Christoph 2bacd473fc Update version to 2026.7.10 2026-07-03 17:17:44 +02:00
Christoph 65e5508d4b Merge pull request 'feat(git): make fetch command asynchronous for improved performance' (#11) from bug/blocking_ui into master
publish / publish-tauri (, windows-latest) (release) Successful in 23m55s
Reviewed-on: #11
2026-07-03 15:15:51 +00:00
Christoph Brandau 1576f61234 feat(git): make fetch command asynchronous for improved performance
The fetch command has been updated to run asynchronously, allowing for
better performance and responsiveness in the application. This change
ensures that the command does not block the main thread, enhancing the
user experience when interacting with repositories.

- Fetch command now uses async/await for non-blocking execution
- Added a grace period to prevent immediate background fetch after
  switching repositories
- Improved handling of authentication errors during fetch operations
2026-07-03 17:14:53 +02:00
Christoph 08de211ba0 Update version to 2026.7.9 2026-07-03 16:43:53 +02:00
Christoph 7001616779 Merge pull request 'Features/badges' (#10) from features/badges into master
publish / publish-tauri (, windows-latest) (release) Successful in 27m44s
Reviewed-on: #10
2026-07-03 14:41:35 +00:00
Christoph Brandau 9228cd1e08 Merge branch 'features/badges' of https://git.cbsk-tech.de/Christoph/GitLite into features/badges 2026-07-03 16:40:57 +02:00
Christoph Brandau 399ba80550 feat(tauri): update Windows taskbar badge with combined count
The Windows overlay badge now renders a single combined number and
includes an additional “changes” component in the total. This makes
the badge more informative while keeping the icon compact and
legible across small sizes.

- Simplify badge rendering to one circle with centered text
- Extend the Tauri command and frontend call to pass changes count
2026-07-03 16:40:47 +02:00
Christoph Brandau b69538de85 chore(badge): adjust badge icon sizing constants
The badge icon rendering was tweaked by slightly reducing the
radius and scale constants. This helps align the icon proportions
and spacing for a cleaner visual result.
2026-07-03 14:51:12 +02:00
Christoph 7b90f4bcc2 Update README.md 2026-07-03 12:42:14 +00:00
Christoph Brandau 96eb8c109c feat(git): add authenticated fetch and dual sync badge
This updates the Windows taskbar overlay badge to show ahead and
behind separately with distinct colors, and clears it when both are
zero. It also adds a new authenticated fetch command wired through
the Tauri backend and UI, including a silent background fetch to keep
ahead/behind (and the badge) accurate without user interaction.

- Add Windows dual badge rendering for ahead/behind
- Implement Tauri fetch command with auth error handling
- Add UI fetch action plus periodic background fetch updates
2026-07-03 14:29:29 +02:00
Christoph Brandau 276dfbab07 feat(tauri): add Windows taskbar overlay badge for git sync status
This change introduces a Windows-only taskbar badge that displays the
ahead+behind count as a rendered overlay icon. The UI now updates
the badge whenever the repository sync status changes, and the git
diff commands are adjusted to avoid external diff/text conversion noise.

- Add badge rendering and Tauri command for setting overlay icon
- Wire badge updates into the Svelte app based on git status
- Update git diff invocations to disable ext-diff/textconv
2026-07-03 13:49:43 +02:00
Christoph 883cf3ebd1 Update version to 2026.7.8 2026-07-03 07:35:17 +02:00
Christoph 6e283ddf83 Merge pull request 'Features/ai commits' (#9) from features/ai_commits into master
publish / publish-tauri (, windows-latest) (release) Successful in 31m38s
Reviewed-on: #9
2026-07-03 05:27:15 +00:00
Christoph Brandau 979d5aed80 feat(ai): disable local provider and improve commit file display
Local AI is now treated as unavailable in the settings UI, with a
migration to ensure any previously saved "local" selection switches
back to OpenAI. The history panel also improves how commit file
names are presented, including clearer old->new path formatting.

- Migrate stored AI provider away from local to prevent dead state
- Disable local provider option with an "in development" badge
- Refine history panel filename rendering and tooltip context
2026-07-03 07:26:23 +02:00
Christoph Brandau 791d686c48 feat(commit-ai): enhance commit message generation and caching
Improve the commit message generation process by adding a caching mechanism and refining the input handling. This change aims to enhance performance and prevent redundant computations when generating commit messages based on staged changes.

- **src-tauri/crates/commit_ai/src/cloud.rs**:
  - Introduced `looks_like_diff_echo` function to detect if the model's output is a diff instead of a commit message.
  - Updated `openai_compatible_request` and `generate_anthropic` to utilize the new function for error handling.

- **src-tauri/crates/commit_ai/src/lib.rs**:
  - Added `LocalGenerationProfile` enum for managing different generation profiles.
  - Implemented caching for generated messages to avoid redundant processing.
  - Updated `generate_commit_message` to incorporate caching logic.

- **src-tauri/src/git.rs**:
  - Added `staged_diff_local` function to handle local profile generation and exclude specific lock files from the diff.
  - Modified `commit_ai_generate` to accept and process the local generation profile.

- **src/App.svelte**:
  - Added `lastLocalAiGeneratedMessage` state to track the last generated message and prevent unnecessary updates.

- **.claude/settings.local.json**:
  - Updated settings to include additional commands for better functionality.
2026-07-03 07:03:35 +02:00
Christoph 7ca6abf962 Update version to 2026.7.7 2026-07-02 22:17:49 +02:00
29 changed files with 3493 additions and 262 deletions
+8 -1
View File
@@ -71,7 +71,14 @@
"Bash(echo \"EXIT:$?\")", "Bash(echo \"EXIT:$?\")",
"Bash(ls target/)", "Bash(ls target/)",
"Bash(rustup target *)", "Bash(rustup target *)",
"Bash(echo \"exit code: $?\")" "Bash(echo \"exit code: $?\")",
"Read(//home/cbr/.cargo/registry/src/**)",
"Bash(find / -maxdepth 6 -iname \"mistralrs-*\" -type d)",
"Bash(grep -A2 '^name = \"tauri\"$' \"/mnt/c/Users/cbr/Desktop/Neuer Ordner \\(6\\)/src-tauri/Cargo.lock\")",
"Bash(rustfmt --edition 2024 --check src/badge.rs src/main.rs)",
"Bash(rustfmt --edition 2024 --check src/git.rs)",
"Bash(rustfmt --edition 2024 --check src/badge.rs)",
"Bash(rustfmt --edition 2024 --check src/git.rs src/main.rs)"
] ]
} }
} }
-44
View File
@@ -1,45 +1 @@
# Tauri Git Lite
Eine kleine Git-Desktop-App mit Tauri, Rust und Svelte.
## Funktionen
- Repository per lokalem Pfad öffnen
- aktuellen Branch, Upstream, Ahead/Behind und Arbeitsbaumstatus anzeigen
- Branches auflisten und auschecken
- Branches in den aktuellen Branch mergen
- Commit-History mit geänderten Dateien anzeigen
- Explorer-Ansicht mit Ordnerbaum sowie getrackten und ungetrackten Dateien
- Datei- und Ordner-History direkt aus dem Explorer anzeigen
- einzelne Dateien stagen, unstagen und wiederherstellen
- einzelne Dateien oder ganze Ordner aus einem History-Commit wiederherstellen
- aktuellen Branch auf einen ausgewählten Commit zurücksetzen
- Commit mit Message erstellen
- Pull mit Fast-Forward-Strategie
- Push auf den konfigurierten Upstream
## Entwicklung
```powershell
npm install
npm run tauri:dev
```
Frontend allein:
```powershell
npm run dev
```
Checks:
```powershell
npm run check
npm run build
cd src-tauri
cargo check
```
## Hinweis
Die App nutzt das lokal installierte `git` CLI. Ein Repository muss daher bereits auf der Maschine vorhanden sein, und Push/Pull verwenden die Credentials, die Git lokal kennt.
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "tauri-git-lite", "name": "git-lite",
"version": "2026.7.6", "version": "2026.7.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "tauri-git-lite", "name": "git-lite",
"version": "2026.7.6", "version": "2026.7.10",
"dependencies": { "dependencies": {
"@lucide/svelte": "^1.21.0", "@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "git-lite", "name": "git-lite",
"version": "2026.7.6", "version": "2026.7.11",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+31 -12
View File
@@ -2,7 +2,7 @@ use std::time::Duration;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{build_messages, sanitize_message}; use crate::{build_messages, looks_like_diff_echo, sanitize_message};
// Generous sizing so a detailed body with bullet points isn't cut off. // Generous sizing so a detailed body with bullet points isn't cut off.
const DEFAULT_MAX_TOKENS: u32 = 1500; const DEFAULT_MAX_TOKENS: u32 = 1500;
@@ -56,8 +56,14 @@ async fn openai_compatible_request(
let body = OpenAiRequest { let body = OpenAiRequest {
model: model.to_string(), model: model.to_string(),
messages: vec![ messages: vec![
OpenAiMessage { role: "system", content: system }, OpenAiMessage {
OpenAiMessage { role: "user", content: user }, role: "system",
content: system,
},
OpenAiMessage {
role: "user",
content: user,
},
], ],
temperature: 0.3, temperature: 0.3,
}; };
@@ -82,17 +88,22 @@ async fn openai_compatible_request(
return Err(format!("API error ({status}): {text}")); return Err(format!("API error ({status}): {text}"));
} }
let parsed: OpenAiResponse = serde_json::from_str(&text) let parsed: OpenAiResponse =
.map_err(|err| format!("Could not process response: {err}"))?; serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
parsed let message = parsed
.choices .choices
.into_iter() .into_iter()
.next() .next()
.and_then(|choice| choice.message.content) .and_then(|choice| choice.message.content)
.map(|content| sanitize_message(&content)) .map(|content| sanitize_message(&content))
.filter(|content| !content.is_empty()) .filter(|content| !content.is_empty())
.ok_or_else(|| "The model did not return a response.".to_string()) .ok_or_else(|| "The model did not return a response.".to_string())?;
if looks_like_diff_echo(&message) {
return Err("The model returned the diff instead of a commit message.".to_string());
}
Ok(message)
} }
pub async fn generate_openai( pub async fn generate_openai(
@@ -168,7 +179,10 @@ pub async fn generate_anthropic(
model: model.to_string(), model: model.to_string(),
max_tokens: DEFAULT_MAX_TOKENS, max_tokens: DEFAULT_MAX_TOKENS,
system, system,
messages: vec![AnthropicMessage { role: "user", content: user }], messages: vec![AnthropicMessage {
role: "user",
content: user,
}],
}; };
let client = http_client()?; let client = http_client()?;
@@ -190,14 +204,19 @@ pub async fn generate_anthropic(
return Err(format!("API error ({status}): {text}")); return Err(format!("API error ({status}): {text}"));
} }
let parsed: AnthropicResponse = serde_json::from_str(&text) let parsed: AnthropicResponse =
.map_err(|err| format!("Could not process response: {err}"))?; serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
parsed let message = parsed
.content .content
.into_iter() .into_iter()
.find_map(|block| block.text) .find_map(|block| block.text)
.map(|text| sanitize_message(&text)) .map(|text| sanitize_message(&text))
.filter(|text| !text.is_empty()) .filter(|text| !text.is_empty())
.ok_or_else(|| "The model did not return a response.".to_string()) .ok_or_else(|| "The model did not return a response.".to_string())?;
if looks_like_diff_echo(&message) {
return Err("The model returned the diff instead of a commit message.".to_string());
}
Ok(message)
} }
+213 -27
View File
@@ -2,9 +2,13 @@ mod cloud;
pub use cloud::{generate_anthropic, generate_custom, generate_openai}; pub use cloud::{generate_anthropic, generate_custom, generate_openai};
use std::sync::Arc; use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
sync::Arc,
};
use mistralrs::{GgufModelBuilder, Model, TextMessageRole, TextMessages}; use mistralrs::{GgufModelBuilder, Model, RequestBuilder, TextMessageRole};
use tokio::sync::RwLock; use tokio::sync::RwLock;
/// One selectable local (on-device) model. Larger models produce better commit messages /// One selectable local (on-device) model. Larger models produce better commit messages
@@ -19,7 +23,7 @@ pub struct LocalModelOption {
tokenizer_repo: &'static str, tokenizer_repo: &'static str,
} }
pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-1.5b"; pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-0.5b";
pub const LOCAL_MODELS: &[LocalModelOption] = &[ pub const LOCAL_MODELS: &[LocalModelOption] = &[
LocalModelOption { LocalModelOption {
@@ -52,6 +56,59 @@ fn find_local_model(model_id: &str) -> Option<&'static LocalModelOption> {
LOCAL_MODELS.iter().find(|option| option.id == model_id) LOCAL_MODELS.iter().find(|option| option.id == model_id)
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LocalGenerationProfile {
Fast,
Balanced,
Detailed,
}
impl Default for LocalGenerationProfile {
fn default() -> Self {
Self::Fast
}
}
impl LocalGenerationProfile {
pub fn from_id(value: Option<&str>) -> Self {
match value
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"balanced" => Self::Balanced,
"detailed" => Self::Detailed,
_ => Self::Fast,
}
}
pub fn diff_unified_context(self) -> &'static str {
match self {
Self::Fast => "--unified=1",
Self::Balanced => "--unified=2",
Self::Detailed => "--unified=3",
}
}
fn max_diff_chars(self) -> usize {
match self {
Self::Fast => 8_000,
Self::Balanced => 12_000,
Self::Detailed => 24_000,
}
}
fn max_output_tokens(self) -> usize {
match self {
Self::Fast => 160,
Self::Balanced => 360,
Self::Detailed => 750,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum CommitAiPhase { pub enum CommitAiPhase {
@@ -75,6 +132,20 @@ struct Inner {
model_id: Option<String>, model_id: Option<String>,
error: Option<String>, error: Option<String>,
model: Option<Arc<Model>>, model: Option<Arc<Model>>,
cache: Option<GenerationCache>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GenerationCacheKey {
model_id: String,
profile: LocalGenerationProfile,
input_hash: u64,
}
#[derive(Debug, Clone)]
struct GenerationCache {
key: GenerationCacheKey,
message: String,
} }
/// Manages the local (on-device) model only. Cloud providers are stateless HTTP calls /// Manages the local (on-device) model only. Cloud providers are stateless HTTP calls
@@ -92,6 +163,7 @@ impl Default for CommitAiEngine {
model_id: None, model_id: None,
error: None, error: None,
model: None, model: None,
cache: None,
})), })),
} }
} }
@@ -129,6 +201,7 @@ impl CommitAiEngine {
guard.phase = CommitAiPhase::Error; guard.phase = CommitAiPhase::Error;
guard.model_id = Some(model_id.to_string()); guard.model_id = Some(model_id.to_string());
guard.error = Some(format!("Unknown local model: {model_id}")); guard.error = Some(format!("Unknown local model: {model_id}"));
guard.cache = None;
return; return;
}; };
@@ -138,6 +211,7 @@ impl CommitAiEngine {
guard.model_id = Some(model_id.to_string()); guard.model_id = Some(model_id.to_string());
guard.error = None; guard.error = None;
guard.model = None; guard.model = None;
guard.cache = None;
} }
let result = GgufModelBuilder::new(option.repo, vec![option.file]) let result = GgufModelBuilder::new(option.repo, vec![option.file])
@@ -157,10 +231,12 @@ impl CommitAiEngine {
guard.model = Some(Arc::new(model)); guard.model = Some(Arc::new(model));
guard.phase = CommitAiPhase::Ready; guard.phase = CommitAiPhase::Ready;
guard.error = None; guard.error = None;
guard.cache = None;
} }
Err(err) => { Err(err) => {
guard.phase = CommitAiPhase::Error; guard.phase = CommitAiPhase::Error;
guard.error = Some(err.to_string()); guard.error = Some(err.to_string());
guard.cache = None;
} }
} }
} }
@@ -169,22 +245,36 @@ impl CommitAiEngine {
&self, &self,
diff: &str, diff: &str,
notes: Option<&str>, notes: Option<&str>,
profile: LocalGenerationProfile,
) -> Result<String, String> { ) -> Result<String, String> {
let model = { let (model, cache_key) = {
let guard = self.inner.read().await; let guard = self.inner.read().await;
match (guard.phase, &guard.model) { match (guard.phase, &guard.model) {
(CommitAiPhase::Ready, Some(model)) => model.clone(), (CommitAiPhase::Ready, Some(model)) => {
let cache_key = GenerationCacheKey {
model_id: guard.model_id.clone().unwrap_or_default(),
profile,
input_hash: generation_input_hash(diff, notes),
};
if let Some(cache) = &guard.cache {
if cache.key == cache_key {
return Ok(cache.message.clone());
}
}
(model.clone(), cache_key)
}
_ => return Err("The local AI model is not ready yet.".to_string()), _ => return Err("The local AI model is not ready yet.".to_string()),
} }
}; };
let (system, user) = build_messages(diff, notes)?; let (system, user) = build_local_messages(diff, notes, profile)?;
let messages = TextMessages::new() let request = RequestBuilder::new()
.set_sampler_max_len(profile.max_output_tokens())
.add_message(TextMessageRole::System, system) .add_message(TextMessageRole::System, system)
.add_message(TextMessageRole::User, user); .add_message(TextMessageRole::User, user);
let response = model let response = model
.send_chat_request(messages) .send_chat_request(request)
.await .await
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
@@ -198,10 +288,29 @@ impl CommitAiEngine {
if message.is_empty() { if message.is_empty() {
return Err("The model did not return a response.".to_string()); return Err("The model did not return a response.".to_string());
} }
if looks_like_diff_echo(&message) {
return Err(
"The local model returned the diff instead of a commit message. Try a larger local model (1.5B or 3B) or a cloud provider.".to_string(),
);
}
{
let mut guard = self.inner.write().await;
guard.cache = Some(GenerationCache {
key: cache_key,
message: message.clone(),
});
}
Ok(message) Ok(message)
} }
} }
fn generation_input_hash(diff: &str, notes: Option<&str>) -> u64 {
let mut hasher = DefaultHasher::new();
diff.hash(&mut hasher);
notes.unwrap_or("").hash(&mut hasher);
hasher.finish()
}
/// Models occasionally ignore the "no code fences" instruction (small local models /// Models occasionally ignore the "no code fences" instruction (small local models
/// especially) — strip a wrapping ``` fence and wrapping quotes so the result can go /// especially) — strip a wrapping ``` fence and wrapping quotes so the result can go
/// straight into the commit-message box. /// straight into the commit-message box.
@@ -221,6 +330,74 @@ pub(crate) fn sanitize_message(raw: &str) -> String {
trimmed.to_string() trimmed.to_string()
} }
/// Weak models (small local ones especially) sometimes just echo the prompt's diff
/// sections back instead of writing a commit message. Catch that so the UI can show a
/// clear error instead of dumping raw diff text into the commit-message box.
pub(crate) fn looks_like_diff_echo(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("diff --git")
|| lower.contains("staged files:")
|| lower.contains("staged changes:")
|| lower.contains("diff stat:")
|| lower.contains("detailed diff:")
|| message.lines().any(|line| line.starts_with("@@ "))
}
fn truncate_at_char_boundary(input: &str, max_chars: usize) -> String {
if input.len() <= max_chars {
return input.to_string();
}
let mut cut = max_chars;
while !input.is_char_boundary(cut) {
cut -= 1;
}
format!("{}\n\n[... diff truncated ...]", &input[..cut])
}
pub(crate) fn build_local_messages(
diff: &str,
notes: Option<&str>,
profile: LocalGenerationProfile,
) -> Result<(String, String), String> {
if diff.trim().is_empty() {
return Err("No staged changes available for a commit message.".to_string());
}
let diff = truncate_at_char_boundary(diff, profile.max_diff_chars());
// Appended to every profile below: small local models occasionally just echo the input
// (the "Staged files:" / "Diff stat:" / "Detailed diff:" sections built in git.rs's
// `staged_diff_local`) instead of writing a new commit message. Naming those exact
// section headers here makes the failure mode explicit enough for weak models to avoid.
const ANTI_ECHO: &str = " Never repeat, quote, or paraphrase the diff or its headers — \
do not include 'diff --git', '@@', 'Staged files:', 'Diff stat:', or 'Detailed diff:' \
anywhere in your answer.";
let system = match profile {
LocalGenerationProfile::Fast => {
format!(
"You generate Git commit messages. Respond only with one Conventional Commits subject line: <type>(<scope>): <subject>. Max 72 characters. No body, bullets, preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
LocalGenerationProfile::Balanced => {
format!(
"You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then at most two short bullet points. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
LocalGenerationProfile::Detailed => {
format!(
"You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then a concise body and up to four short bullet points grouped by affected area/file. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
};
let mut user = String::new();
if let Some(n) = notes.filter(|n| !n.trim().is_empty()) {
user.push_str(&format!("Developer notes:\n{n}\n\n"));
}
user.push_str(&format!("Staged changes:\n{diff}"));
Ok((system, user))
}
pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> { pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> {
if diff.trim().is_empty() { if diff.trim().is_empty() {
return Err("No staged changes available for a commit message.".to_string()); return Err("No staged changes available for a commit message.".to_string());
@@ -228,26 +405,35 @@ pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String,
// Rough token estimate — small models often have an 8-32k context window. // Rough token estimate — small models often have an 8-32k context window.
const MAX_CHARS: usize = 24_000; const MAX_CHARS: usize = 24_000;
let diff = if diff.len() > MAX_CHARS { let diff = truncate_at_char_boundary(diff, MAX_CHARS);
// Pull the byte index back to a valid UTF-8 char boundary, otherwise
// slicing mid-multi-byte-character would panic.
let mut cut = MAX_CHARS;
while !diff.is_char_boundary(cut) {
cut -= 1;
}
format!("{}\n\n[... diff truncated ...]", &diff[..cut])
} else {
diff.to_string()
};
let system = "You are a tool that generates Git commit messages. \ let system = "You are a tool that writes a Git commit message describing a staged diff. \
Respond only with the commit message in Conventional Commits format \ Output ONLY the commit message text. Never quote, restate, or paraphrase the diff itself: \
(<type>(<scope>): <subject>), followed by a body after a blank line. \ do not include lines starting with 'diff --git', '@@', '+', '-', 'index ', 'Staged files:', \
Subject in imperative mood, max. 72 characters. \ or 'Diff stat:' anywhere in your answer. \
The body is required: summarize in a short paragraph what changed and why, \ Format: a Conventional Commits header (<type>(<scope>): <subject>) in imperative mood, \
then list the key changes as bullet points (- ...), \ max. 72 characters, then a blank line, then a body. \
grouped by affected area/file. Lines in the body max. 72 characters. \ The body is required: a short, general paragraph (2-4 sentences) summarizing what changed \
No preamble, no explanation, no code fences, answer in English" and why at a high level — do NOT enumerate every changed file individually. \
You may optionally add up to 3 bullet points (- ...) afterward, but only for the most \
significant changes overall, never one bullet or heading per file. \
Never use bold text, backticks, or markdown headings for file names. \
Lines in the body max. 72 characters. \
No preamble, no explanation, no code fences, answer in English.\n\n\
Example:\n\
Diff:\n\
diff --git a/src/auth.py b/src/auth.py\n\
+def hash_password(pw):\n\
+ return bcrypt.hash(pw)\n\
diff --git a/src/routes.py b/src/routes.py\n\
-if password == stored_password:\n\
+if bcrypt.check(password, stored_password):\n\n\
Commit message:\n\
feat(auth): hash and verify passwords with bcrypt\n\n\
Passwords were previously compared as plain text. This adds a bcrypt-based\n\
hashing helper and updates the login check to verify against the hash\n\
instead of a direct string comparison.\n\n\
- Hash passwords on write, verify with bcrypt on login"
.to_string(); .to_string();
let mut user = String::new(); let mut user = String::new();
+186
View File
@@ -0,0 +1,186 @@
//! Renders the combined repository attention count as a taskbar badge and applies it as the
//! Windows taskbar overlay. Windows has no native numeric taskbar badge (unlike
//! macOS/Linux, which support `Window::set_badge_count`), so the numbers have to be drawn
//! into a plain RGBA icon ourselves and set via `Window::set_overlay_icon`.
use tauri::Manager;
use tauri::image::Image;
const GLYPH_WIDTH: usize = 3;
const GLYPH_HEIGHT: usize = 5;
/// A minimal 3x5 pixel-grid font, just enough to draw digits and "+" legibly at badge size.
fn glyph_rows(ch: char) -> [&'static str; GLYPH_HEIGHT] {
match ch {
'0' => ["111", "101", "101", "101", "111"],
'1' => ["010", "110", "010", "010", "111"],
'2' => ["111", "001", "111", "100", "111"],
'3' => ["111", "001", "111", "001", "111"],
'4' => ["101", "101", "111", "001", "001"],
'5' => ["111", "100", "111", "001", "111"],
'6' => ["111", "100", "111", "101", "111"],
'7' => ["111", "001", "010", "010", "010"],
'8' => ["111", "101", "111", "101", "111"],
'9' => ["111", "101", "111", "001", "111"],
'+' => ["000", "010", "111", "010", "000"],
_ => ["000", "000", "000", "000", "000"],
}
}
const BADGE_FILL: [u8; 4] = [224, 160, 64, 255];
const BADGE_BORDER: [u8; 4] = [176, 118, 40, 255];
const TEXT_FILL: [u8; 4] = [255, 255, 255, 255];
const TEXT_SHADOW: [u8; 4] = [10, 12, 24, 190];
const CIRCLE_BORDER_WIDTH: f32 = 3.0;
/// Caps the displayed text at three characters ("99+") so it always fits legibly.
fn cap_text(count: u32) -> String {
if count > 99 {
"99+".to_string()
} else {
count.to_string()
}
}
/// Draws a filled circle (with a slightly darker rim) centered at `(cx, cy)`.
fn draw_circle(
rgba: &mut [u8],
size: usize,
cx: f32,
cy: f32,
radius: f32,
fill: [u8; 4],
border: [u8; 4],
) {
for y in 0..size {
for x in 0..size {
let dx = x as f32 + 0.5 - cx;
let dy = y as f32 + 0.5 - cy;
let dist = (dx * dx + dy * dy).sqrt();
if dist <= radius {
let color = if dist >= radius - CIRCLE_BORDER_WIDTH {
border
} else {
fill
};
let idx = (y * size + x) * 4;
rgba[idx..idx + 4].copy_from_slice(&color);
}
}
}
}
fn draw_text_layer(
rgba: &mut [u8],
size: usize,
text: &str,
cx: f32,
cy: f32,
scale: usize,
offset: (isize, isize),
color: [u8; 4],
) {
let glyphs: Vec<[&'static str; GLYPH_HEIGHT]> = text.chars().map(glyph_rows).collect();
let glyph_px_w = GLYPH_WIDTH * scale;
let glyph_px_h = GLYPH_HEIGHT * scale;
let gap = scale.max(1);
let total_w = glyphs.len() * glyph_px_w + gap * glyphs.len().saturating_sub(1);
let start_x = (cx - total_w as f32 / 2.0).round() as isize + offset.0;
let start_y = (cy - glyph_px_h as f32 / 2.0).round() as isize + offset.1;
for (gi, rows) in glyphs.iter().enumerate() {
let glyph_x = start_x + (gi * (glyph_px_w + gap)) as isize;
for (gy, row) in rows.iter().enumerate() {
for (gx, pixel) in row.chars().enumerate() {
if pixel != '1' {
continue;
}
for py in 0..scale {
for px in 0..scale {
let x = glyph_x + (gx * scale + px) as isize;
let y = start_y + (gy * scale + py) as isize;
if x >= 0 && y >= 0 && (x as usize) < size && (y as usize) < size {
let idx = (y as usize * size + x as usize) * 4;
rgba[idx..idx + 4].copy_from_slice(&color);
}
}
}
}
}
}
}
/// Draws white `text` centered at `(cx, cy)`, scaling each font pixel up by `scale`.
fn draw_text(rgba: &mut [u8], size: usize, text: &str, cx: f32, cy: f32, scale: usize) {
for offset in [(-1, 0), (1, 0), (0, -1), (0, 1)] {
draw_text_layer(rgba, size, text, cx, cy, scale, offset, TEXT_SHADOW);
}
draw_text_layer(rgba, size, text, cx, cy, scale, (0, 0), TEXT_FILL);
}
fn text_scale(text: &str) -> usize {
match text.len() {
0 | 1 => 7,
2 => 5,
_ => 4,
}
}
/// Renders a single large badge with `count`. Returns `None` when there's nothing to show,
/// so the caller can clear the overlay icon.
fn render_badge_icon(count: u32) -> Option<Image<'static>> {
if count == 0 {
return None;
}
const SIZE: usize = 64;
const RADIUS: f32 = 29.0;
let mut rgba = vec![0u8; SIZE * SIZE * 4];
let center = SIZE as f32 / 2.0;
let text = cap_text(count);
draw_circle(
&mut rgba,
SIZE,
center,
center,
RADIUS,
BADGE_FILL,
BADGE_BORDER,
);
draw_text(&mut rgba, SIZE, &text, center, center, text_scale(&text));
Some(Image::new_owned(rgba, SIZE as u32, SIZE as u32))
}
/// Sets the taskbar badge to `ahead + behind + changes` (0 clears it). Windows-only: Windows
/// has no native numeric badge API, so this draws and applies a small overlay icon instead.
/// No-op on other platforms: non-Windows desktops should use `Window::set_badge_count`
/// for a real native badge instead, which this app doesn't currently wire up.
#[tauri::command]
pub fn set_sync_badge(
app: tauri::AppHandle,
ahead: u32,
behind: u32,
changes: u32,
) -> Result<(), String> {
let count = ahead.saturating_add(behind).saturating_add(changes);
#[cfg(target_os = "windows")]
{
let Some(window) = app.get_webview_window("main") else {
return Ok(());
};
let icon = render_badge_icon(count);
window
.set_overlay_icon(icon)
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
}
#[cfg(not(target_os = "windows"))]
{
let _ = (app, count);
}
Ok(())
}
+648 -25
View File
@@ -47,6 +47,7 @@ pub struct GitStatus {
pub behind: u32, pub behind: u32,
pub files: Vec<GitFileStatus>, pub files: Vec<GitFileStatus>,
pub clean: bool, pub clean: bool,
pub rebase_in_progress: bool,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -56,6 +57,16 @@ pub struct GitBranch {
pub remote: bool, pub remote: bool,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitStash {
pub selector: String,
pub index: u32,
pub hash: String,
pub branch: Option<String>,
pub message: String,
pub date: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitCommit { pub struct GitCommit {
pub hash: String, pub hash: String,
@@ -234,10 +245,34 @@ pub fn open_repository_file(path: String, file: String) -> Result<(), String> {
pub struct RepositoryBundle { pub struct RepositoryBundle {
pub status: GitStatus, pub status: GitStatus,
pub branches: Vec<GitBranch>, pub branches: Vec<GitBranch>,
pub stashes: Vec<GitStash>,
pub commits: Vec<GitCommit>, pub commits: Vec<GitCommit>,
pub files: Vec<GitRepositoryFile>, pub files: Vec<GitRepositoryFile>,
} }
#[tauri::command]
pub async fn clone_repository(
remote_url: String,
parent_path: String,
directory_name: Option<String>,
username: Option<String>,
password: Option<String>,
commit_limit: Option<u32>,
) -> Result<RepositoryBundle, String> {
tauri::async_runtime::spawn_blocking(move || {
clone_repository_core(
&remote_url,
&parent_path,
directory_name.as_deref(),
username.as_deref(),
password.as_deref(),
commit_limit,
)
})
.await
.map_err(|err| format!("Could not clone repository: {err}"))?
}
/// Opens a repository and gathers everything the UI needs in a single call. /// Opens a repository and gathers everything the UI needs in a single call.
/// ///
/// Runs on a blocking thread (so the UI/overlay stays responsive) and resolves /// Runs on a blocking thread (so the UI/overlay stays responsive) and resolves
@@ -252,11 +287,13 @@ pub async fn open_repository_bundle(
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let status = status_for_repo(&repo)?; let status = status_for_repo(&repo)?;
let branches = branches_for_repo(&repo)?; let branches = branches_for_repo(&repo)?;
let stashes = stashes_for_repo(&repo)?;
let commits = commits_for_repo(&repo, commit_limit)?; let commits = commits_for_repo(&repo, commit_limit)?;
let files = repository_files_with_status(&repo, &status)?; let files = repository_files_with_status(&repo, &status)?;
Ok(RepositoryBundle { Ok(RepositoryBundle {
status, status,
branches, branches,
stashes,
commits, commits,
files, files,
}) })
@@ -277,6 +314,12 @@ pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
branches_for_repo(&repo) branches_for_repo(&repo)
} }
#[tauri::command]
pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
let repo = resolve_repo(&path)?;
stashes_for_repo(&repo)
}
fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> { fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
let output = run_git( let output = run_git(
repo, repo,
@@ -316,6 +359,129 @@ fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
Ok(branches) Ok(branches)
} }
fn stashes_for_repo(repo: &Path) -> Result<Vec<GitStash>, String> {
let output = run_git(repo, ["stash", "list", "--format=%gd%x00%H%x00%cr%x00%gs"])?;
let text = String::from_utf8_lossy(&output);
let mut stashes = Vec::new();
for line in text.lines() {
let mut parts = line.splitn(4, '\0');
let selector = parts.next().unwrap_or_default().trim();
let hash = parts.next().unwrap_or_default().trim();
let date = parts.next().unwrap_or_default().trim();
let subject = parts.next().unwrap_or_default().trim();
if selector.is_empty() {
continue;
}
let index = stash_index_from_selector(selector).unwrap_or(stashes.len() as u32);
let (branch, message) = parse_stash_subject(subject);
stashes.push(GitStash {
selector: selector.to_string(),
index,
hash: hash.to_string(),
branch,
message,
date: date.to_string(),
});
}
Ok(stashes)
}
fn stash_index_from_selector(selector: &str) -> Option<u32> {
selector
.strip_prefix("stash@{")
.and_then(|value| value.strip_suffix('}'))
.and_then(|value| value.parse::<u32>().ok())
}
fn parse_stash_subject(subject: &str) -> (Option<String>, String) {
for prefix in ["WIP on ", "On "] {
if let Some(value) = subject.strip_prefix(prefix) {
if let Some((branch, rest)) = value.split_once(": ") {
let message = if prefix == "WIP on " {
rest.split_once(' ').map(|(_, msg)| msg).unwrap_or(rest)
} else {
rest
};
return (Some(branch.to_string()), message.to_string());
}
}
}
(None, subject.to_string())
}
#[tauri::command]
pub fn stash_push(
path: String,
message: Option<String>,
include_untracked: bool,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let trimmed_message = message.unwrap_or_default().trim().to_string();
let mut args: Vec<OsString> = vec![OsString::from("stash"), OsString::from("push")];
if include_untracked {
args.push(OsString::from("--include-untracked"));
}
if !trimmed_message.is_empty() {
args.push(OsString::from("-m"));
args.push(OsString::from(trimmed_message));
}
run_git(&repo, args)?;
status_for_repo(&repo)
}
#[tauri::command]
pub fn stash_apply(path: String, selector: String) -> Result<GitStatus, String> {
run_stash_update(path, "apply", selector)
}
#[tauri::command]
pub fn stash_pop(path: String, selector: String) -> Result<GitStatus, String> {
run_stash_update(path, "pop", selector)
}
#[tauri::command]
pub fn stash_drop(path: String, selector: String) -> Result<GitStatus, String> {
run_stash_update(path, "drop", selector)
}
fn run_stash_update(path: String, action: &str, selector: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let selector = validate_stash_selector(&selector)?;
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["stash", action, selector.as_str()])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
if output.status.success() {
return status_for_repo(&repo);
}
let status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&status) {
return Ok(status);
}
Err(format!(
"Git command failed: {}",
command_output_details(&output)
))
}
fn validate_stash_selector(selector: &str) -> Result<String, String> {
let selector = selector.trim();
let Some(index) = stash_index_from_selector(selector) else {
return Err("Invalid stash selector.".to_string());
};
Ok(format!("stash@{{{index}}}"))
}
#[tauri::command] #[tauri::command]
pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String> { pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
@@ -387,7 +553,11 @@ pub fn rename_branch(
} }
#[tauri::command] #[tauri::command]
pub fn delete_branch(path: String, branch: String) -> Result<GitStatus, String> { pub fn delete_branch(
path: String,
branch: String,
force: Option<bool>,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let branch = validate_existing_local_branch_name(&repo, &branch)?; let branch = validate_existing_local_branch_name(&repo, &branch)?;
let status = status_for_repo(&repo)?; let status = status_for_repo(&repo)?;
@@ -395,7 +565,8 @@ pub fn delete_branch(path: String, branch: String) -> Result<GitStatus, String>
return Err("The current branch cannot be deleted.".to_string()); return Err("The current branch cannot be deleted.".to_string());
} }
run_git(&repo, ["branch", "-d", "--", branch.as_str()])?; let delete_flag = if force.unwrap_or(false) { "-D" } else { "-d" };
run_git(&repo, ["branch", delete_flag, "--", branch.as_str()])?;
status_for_repo(&repo) status_for_repo(&repo)
} }
@@ -540,6 +711,50 @@ fn staged_diff(repo: &Path) -> Result<String, String> {
Ok(format!("Staged files:\n{file_list}\n\n{diff}")) Ok(format!("Staged files:\n{file_list}\n\n{diff}"))
} }
fn staged_diff_local(
repo: &Path,
profile: commit_ai::LocalGenerationProfile,
) -> Result<String, String> {
let name_status = run_git(repo, ["diff", "--cached", "--name-status", "-M"])?;
let file_list = String::from_utf8_lossy(&name_status).trim().to_string();
let stat = run_git(repo, ["diff", "--cached", "--stat", "--summary"])?;
let stat = String::from_utf8_lossy(&stat).trim().to_string();
let diff_args = vec![
"diff",
"--cached",
"--no-ext-diff",
"--no-textconv",
profile.diff_unified_context(),
"--",
".",
":(exclude)*package-lock.json",
":(exclude)*pnpm-lock.yaml",
":(exclude)*yarn.lock",
":(exclude)*bun.lockb",
":(exclude)*Cargo.lock",
":(exclude)*composer.lock",
":(exclude)*Gemfile.lock",
":(exclude)*poetry.lock",
":(exclude)*go.sum",
];
let diff = run_git(repo, diff_args)?;
let diff = String::from_utf8_lossy(&diff).trim().to_string();
let mut sections = Vec::new();
if !file_list.is_empty() {
sections.push(format!("Staged files:\n{file_list}"));
}
if !stat.is_empty() {
sections.push(format!("Diff stat:\n{stat}"));
}
if !diff.is_empty() {
sections.push(format!("Detailed diff:\n{diff}"));
}
Ok(sections.join("\n\n"))
}
#[tauri::command] #[tauri::command]
pub async fn commit_ai_generate( pub async fn commit_ai_generate(
path: String, path: String,
@@ -548,17 +763,27 @@ pub async fn commit_ai_generate(
model: Option<String>, model: Option<String>,
api_key: Option<String>, api_key: Option<String>,
base_url: Option<String>, base_url: Option<String>,
local_profile: Option<String>,
engine: tauri::State<'_, commit_ai::CommitAiEngine>, engine: tauri::State<'_, commit_ai::CommitAiEngine>,
) -> Result<String, String> { ) -> Result<String, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
let diff = staged_diff(&repo)?; let local_profile = commit_ai::LocalGenerationProfile::from_id(local_profile.as_deref());
let diff = if provider == "local" {
staged_diff_local(&repo, local_profile)?
} else {
staged_diff(&repo)?
};
let notes = notes.as_deref(); let notes = notes.as_deref();
let model = model.filter(|value| !value.trim().is_empty()); let model = model.filter(|value| !value.trim().is_empty());
let api_key = api_key.filter(|value| !value.trim().is_empty()); let api_key = api_key.filter(|value| !value.trim().is_empty());
let base_url = base_url.filter(|value| !value.trim().is_empty()); let base_url = base_url.filter(|value| !value.trim().is_empty());
match provider.as_str() { match provider.as_str() {
"local" => engine.generate_commit_message(&diff, notes).await, "local" => {
engine
.generate_commit_message(&diff, notes, local_profile)
.await
}
"openai" => { "openai" => {
let api_key = api_key.ok_or_else(|| "OpenAI API key is missing.".to_string())?; let api_key = api_key.ok_or_else(|| "OpenAI API key is missing.".to_string())?;
let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string()); let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string());
@@ -620,9 +845,7 @@ pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
let current_status = status_for_repo(&repo)?; let current_status = status_for_repo(&repo)?;
if has_unresolved_conflicts(&current_status) { if has_unresolved_conflicts(&current_status) {
return Err( return Err("Merge conflicts must be resolved before you can commit.".to_string());
"Merge conflicts must be resolved before you can commit.".to_string(),
);
} }
run_git(&repo, ["commit", "-m", message.as_str()])?; run_git(&repo, ["commit", "-m", message.as_str()])?;
@@ -646,9 +869,7 @@ pub fn pull(
.arg(&repo) .arg(&repo)
.args(pull_args) .args(pull_args)
.output() .output()
.map_err(|err| { .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
format!("Could not start Git. Is Git installed? {err}")
})?,
}; };
if output.status.success() { if output.status.success() {
@@ -667,6 +888,41 @@ pub fn pull(
Err(format!("Git command failed: {details}")) Err(format!("Git command failed: {details}"))
} }
#[tauri::command]
pub async fn fetch(
path: String,
username: Option<String>,
password: Option<String>,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let fetch_args = ["fetch"];
let output = match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated_output(&repo, fetch_args, u, p)?
}
_ => git_command()
.arg("-C")
.arg(&repo)
.args(fetch_args)
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
};
if output.status.success() {
return status_for_repo(&repo);
}
let details = command_output_details(&output);
if is_auth_error(&details) {
return Err(format!("AUTH_FAILED:{details}"));
}
Err(format!("Git command failed: {details}"))
})
.await
.map_err(|err| format!("Could not fetch repository: {err}"))?
}
#[tauri::command] #[tauri::command]
pub fn push( pub fn push(
path: String, path: String,
@@ -703,8 +959,7 @@ fn cred_entry(key: &str) -> Result<keyring::Entry, String> {
if key.is_empty() { if key.is_empty() {
return Err("No key provided for the credentials.".to_string()); return Err("No key provided for the credentials.".to_string());
} }
keyring::Entry::new(CRED_SERVICE, key) keyring::Entry::new(CRED_SERVICE, key).map_err(|err| format!("Keychain unavailable: {err}"))
.map_err(|err| format!("Keychain unavailable: {err}"))
} }
/// Returns the remote URL used for auth key derivation (upstream remote of the /// Returns the remote URL used for auth key derivation (upstream remote of the
@@ -790,9 +1045,8 @@ fn initial_push_remote_name(repo: &Path) -> Result<String, String> {
return Ok("origin".to_string()); return Ok("origin".to_string());
} }
first_remote_name(repo).ok_or_else(|| { first_remote_name(repo)
"This branch has no upstream and no remote is configured.".to_string() .ok_or_else(|| "This branch has no upstream and no remote is configured.".to_string())
})
} }
fn push_args_for_repo(repo: &Path) -> Result<Vec<OsString>, String> { fn push_args_for_repo(repo: &Path) -> Result<Vec<OsString>, String> {
@@ -894,6 +1148,72 @@ pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
Err(format!("Merge failed: {details}")) Err(format!("Merge failed: {details}"))
} }
#[tauri::command]
pub fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let branch = branch.trim();
if branch.is_empty() {
return Err("Branch name must not be empty.".to_string());
}
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["rebase", branch])
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
rebase_status_or_error(&repo, output, "Rebase failed", true)
}
#[tauri::command]
pub fn rebase_continue(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !rebase_in_progress(&repo) {
return Err("No rebase is currently in progress.".to_string());
}
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["rebase", "--continue"])
.env("GIT_EDITOR", "true")
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
rebase_status_or_error(&repo, output, "Rebase continue failed", false)
}
#[tauri::command]
pub fn rebase_abort(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
if !rebase_in_progress(&repo) {
return Err("No rebase is currently in progress.".to_string());
}
run_git(&repo, ["rebase", "--abort"])?;
status_for_repo(&repo)
}
fn rebase_status_or_error(
repo: &Path,
output: Output,
context: &str,
ok_if_rebase_in_progress: bool,
) -> Result<GitStatus, String> {
if output.status.success() {
return status_for_repo(repo);
}
let status = status_for_repo(repo)?;
if has_unresolved_conflicts(&status) || (ok_if_rebase_in_progress && status.rebase_in_progress)
{
return Ok(status);
}
Err(format!("{context}: {}", command_output_details(&output)))
}
#[tauri::command] #[tauri::command]
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> { pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
@@ -913,6 +1233,8 @@ fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, S
repo, repo,
[ [
"log", "log",
"--all",
"--topo-order",
"--decorate=short", "--decorate=short",
"--name-status", "--name-status",
"-M", "-M",
@@ -1254,6 +1576,8 @@ pub fn compare_commits(
&repo, &repo,
[ [
"diff", "diff",
"--no-ext-diff",
"--no-textconv",
"-M", "-M",
FULL_FILE_DIFF_CONTEXT, FULL_FILE_DIFF_CONTEXT,
from_hash.as_str(), from_hash.as_str(),
@@ -1296,7 +1620,14 @@ pub fn diff_file_against_working_tree(
)?; )?;
let patch_output = run_git_with_paths( let patch_output = run_git_with_paths(
&repo, &repo,
&["diff", "-M", FULL_FILE_DIFF_CONTEXT, commit_hash.as_str()], &[
"diff",
"--no-ext-diff",
"--no-textconv",
"-M",
FULL_FILE_DIFF_CONTEXT,
commit_hash.as_str(),
],
std::slice::from_ref(&file), std::slice::from_ref(&file),
)?; )?;
@@ -1352,6 +1683,8 @@ pub fn compare_file_to_head(
&repo, &repo,
&[ &[
"diff", "diff",
"--no-ext-diff",
"--no-textconv",
"-M", "-M",
FULL_FILE_DIFF_CONTEXT, FULL_FILE_DIFF_CONTEXT,
commit_hash.as_str(), commit_hash.as_str(),
@@ -1424,6 +1757,8 @@ pub fn compare_file_to_parent(
&repo, &repo,
&[ &[
"diff", "diff",
"--no-ext-diff",
"--no-textconv",
"-M", "-M",
FULL_FILE_DIFF_CONTEXT, FULL_FILE_DIFF_CONTEXT,
from_hash.as_str(), from_hash.as_str(),
@@ -1713,9 +2048,30 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
behind: branch.behind, behind: branch.behind,
clean: files.is_empty(), clean: files.is_empty(),
files, files,
rebase_in_progress: rebase_in_progress(repo),
}) })
} }
fn rebase_in_progress(repo: &Path) -> bool {
git_path_exists(repo, "rebase-merge") || git_path_exists(repo, "rebase-apply")
}
fn git_path_exists(repo: &Path, name: &str) -> bool {
let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else {
return false;
};
let value = String::from_utf8_lossy(&output).trim().to_string();
if value.is_empty() {
return false;
}
let path = PathBuf::from(value);
if path.is_absolute() {
path.exists()
} else {
repo.join(path).exists()
}
}
// `git status` only auto-detects renames between HEAD and the index (staged changes). // `git status` only auto-detects renames between HEAD and the index (staged changes).
// A file renamed on disk but not yet `git add`ed shows up as a plain delete + untracked // A file renamed on disk but not yet `git add`ed shows up as a plain delete + untracked
// pair instead. We detect that case ourselves by comparing content hashes: if an unstaged // pair instead. We detect that case ourselves by comparing content hashes: if an unstaged
@@ -1875,6 +2231,166 @@ fn repository_files_with_status(
Ok(files.into_values().collect()) Ok(files.into_values().collect())
} }
fn clone_repository_core(
remote_url: &str,
parent_path: &str,
directory_name: Option<&str>,
username: Option<&str>,
password: Option<&str>,
commit_limit: Option<u32>,
) -> Result<RepositoryBundle, String> {
let target = clone_target_path(remote_url, parent_path, directory_name)?;
run_git_clone(remote_url.trim(), &target, username, password)?;
let repo = resolve_repo(&target.to_string_lossy())?;
let status = status_for_repo(&repo)?;
let branches = branches_for_repo(&repo)?;
let stashes = stashes_for_repo(&repo)?;
let commits = commits_for_repo(&repo, commit_limit)?;
let files = repository_files_with_status(&repo, &status)?;
Ok(RepositoryBundle {
status,
branches,
stashes,
commits,
files,
})
}
fn clone_target_path(
remote_url: &str,
parent_path: &str,
directory_name: Option<&str>,
) -> Result<PathBuf, String> {
let remote = remote_url.trim();
if remote.is_empty() {
return Err("Remote URL must not be empty.".to_string());
}
if remote.starts_with('-') || remote.chars().any(|c| c.is_control()) {
return Err("Remote URL contains invalid characters.".to_string());
}
let parent = PathBuf::from(parent_path.trim());
if parent_path.trim().is_empty() {
return Err("Destination folder must not be empty.".to_string());
}
if !parent.exists() {
return Err("Destination folder does not exist.".to_string());
}
if !parent.is_dir() {
return Err("Destination path must be a folder.".to_string());
}
let raw_name = directory_name
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.unwrap_or_else(|| infer_clone_directory_name(remote));
let name = validate_clone_directory_name(&raw_name)?;
let target = parent.join(name);
if target.exists() {
if !target.is_dir() {
return Err("Clone destination already exists and is not a folder.".to_string());
}
let mut entries = target
.read_dir()
.map_err(|err| format!("Could not inspect clone destination: {err}"))?;
if entries.next().is_some() {
return Err("Clone destination already exists and is not empty.".to_string());
}
}
Ok(target)
}
fn infer_clone_directory_name(remote_url: &str) -> String {
let trimmed = remote_url
.trim()
.split(['?', '#'])
.next()
.unwrap_or(remote_url)
.trim_end_matches(['/', '\\']);
let last_segment = trimmed
.rsplit(['/', '\\', ':'])
.find(|part| !part.trim().is_empty())
.unwrap_or("")
.trim();
last_segment
.strip_suffix(".git")
.unwrap_or(last_segment)
.trim()
.to_string()
}
fn validate_clone_directory_name(name: &str) -> Result<String, String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("Folder name could not be inferred. Enter a folder name.".to_string());
}
if trimmed == "." || trimmed == ".." {
return Err("Folder name is not valid.".to_string());
}
if trimmed.chars().any(|c| {
c.is_control() || matches!(c, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*')
}) {
return Err("Folder name contains invalid characters.".to_string());
}
if Path::new(trimmed).is_absolute() {
return Err("Folder name must be relative.".to_string());
}
Ok(trimmed.to_string())
}
fn run_git_clone(
remote_url: &str,
target: &Path,
username: Option<&str>,
password: Option<&str>,
) -> Result<(), String> {
let mut command = git_command();
command
.arg("clone")
.arg("--")
.arg(remote_url)
.arg(target)
.env("GIT_TERMINAL_PROMPT", "0");
let askpass = match (username, password) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
let askpass = write_askpass_script()?;
command
.env("GIT_ASKPASS", &askpass)
.env("GIT_CRED_USER", u)
.env("GIT_CRED_PASS", p);
Some(askpass)
}
_ => None,
};
let output = command
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"));
if let Some(path) = askpass {
let _ = std::fs::remove_file(path);
}
let output = output?;
if output.status.success() {
return Ok(());
}
let details = command_output_details(&output);
if is_auth_error(&details) {
return Err(format!("AUTH_FAILED:{details}"));
}
Err(format!("Git clone failed: {}", details))
}
fn is_repository_folder_path(repo: &Path, path: &str) -> Result<bool, String> { fn is_repository_folder_path(repo: &Path, path: &str) -> Result<bool, String> {
let normalized = normalize_git_path(path); let normalized = normalize_git_path(path);
if repo.join(path).is_dir() { if repo.join(path).is_dir() {
@@ -2554,9 +3070,7 @@ fn validate_existing_local_branch_name(repo: &Path, branch: &str) -> Result<Stri
let normalized = validate_branch_ref_name(branch)?; let normalized = validate_branch_ref_name(branch)?;
if !ref_exists(repo, &format!("refs/heads/{normalized}"))? { if !ref_exists(repo, &format!("refs/heads/{normalized}"))? {
return Err(format!( return Err(format!("Local branch '{normalized}' was not found."));
"Local branch '{normalized}' was not found."
));
} }
Ok(normalized) Ok(normalized)
@@ -2996,8 +3510,8 @@ where
thread::sleep(Duration::from_millis(60)); thread::sleep(Duration::from_millis(60));
}; };
let stdout = std::fs::read(&stdout_path) let stdout =
.map_err(|err| format!("Could not read Git output: {err}"))?; std::fs::read(&stdout_path).map_err(|err| format!("Could not read Git output: {err}"))?;
let stderr = std::fs::read(&stderr_path) let stderr = std::fs::read(&stderr_path)
.map_err(|err| format!("Could not read Git error output: {err}"))?; .map_err(|err| format!("Could not read Git error output: {err}"))?;
let _ = std::fs::remove_file(&stdout_path); let _ = std::fs::remove_file(&stdout_path);
@@ -3351,6 +3865,51 @@ mod tests {
run_git_test(repo, ["commit", "-q", "-m", "init"]); run_git_test(repo, ["commit", "-q", "-m", "init"]);
} }
#[test]
fn clone_directory_name_is_inferred_from_common_remote_urls() {
assert_eq!(
infer_clone_directory_name("https://github.com/example/project.git"),
"project"
);
assert_eq!(
infer_clone_directory_name("git@github.com:example/project.git"),
"project"
);
assert_eq!(
infer_clone_directory_name("ssh://git@example.com/example/project.git/"),
"project"
);
}
#[test]
fn clone_repository_core_clones_and_returns_repository_bundle() {
let source = init_temp_repo("clone_source");
commit_initial_file(&source.path);
let parent = temp_dir("clone_parent");
let bundle = clone_repository_core(
source.path.to_str().expect("source path should be UTF-8"),
parent.path.to_str().expect("parent path should be UTF-8"),
Some("local-copy"),
None,
None,
Some(100),
)
.expect("repository should clone");
let cloned_repo = parent.path.join("local-copy");
assert_eq!(
PathBuf::from(bundle.status.repo_path),
cloned_repo
.canonicalize()
.expect("clone path should resolve")
);
assert!(cloned_repo.join("old.txt").exists());
assert!(bundle.status.clean);
assert_eq!(bundle.commits.len(), 1);
assert!(bundle.files.iter().any(|file| file.path == "old.txt"));
}
#[test] #[test]
fn search_code_introductions_finds_added_string() { fn search_code_introductions_finds_added_string() {
let repo = init_temp_repo("search_added_string"); let repo = init_temp_repo("search_added_string");
@@ -3775,6 +4334,33 @@ mod tests {
assert!(comparison.patch.contains("+original")); assert!(comparison.patch.contains("+original"));
} }
#[test]
fn commits_for_repo_includes_all_branch_tips_for_graph() {
let repo = init_temp_repo("commits_all_branches");
commit_initial_file(&repo.path);
let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature/graph"]);
fs::write(repo.path.join("feature.txt"), "feature\n")
.expect("feature file should be written");
run_git_test(&repo.path, ["add", "feature.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "feature graph"]);
let feature_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
run_git_test(&repo.path, ["checkout", "-q", base_branch.as_str()]);
fs::write(repo.path.join("main.txt"), "main\n").expect("main file should be written");
run_git_test(&repo.path, ["add", "main.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "main graph"]);
let main_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
let commits = commits_for_repo(&repo.path, Some(10)).expect("commits should load");
assert!(commits.iter().any(|commit| commit.hash == main_commit));
assert!(commits.iter().any(|commit| {
commit.hash == feature_commit && commit.refs.iter().any(|r| r.contains("feature/graph"))
}));
}
#[test] #[test]
#[cfg_attr( #[cfg_attr(
windows, windows,
@@ -4169,8 +4755,12 @@ mod tests {
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["branch", "stale"]); run_git_test(&repo.path, ["branch", "stale"]);
let status = let status = delete_branch(
delete_branch(repo.path.to_string_lossy().to_string(), "stale".to_string()).unwrap(); repo.path.to_string_lossy().to_string(),
"stale".to_string(),
None,
)
.unwrap();
assert_eq!(status.current_branch.as_deref(), Some(current.as_str())); assert_eq!(status.current_branch.as_deref(), Some(current.as_str()));
assert!( assert!(
@@ -4178,8 +4768,41 @@ mod tests {
"deleted branch should be gone" "deleted branch should be gone"
); );
let err = delete_branch(repo.path.to_string_lossy().to_string(), current).unwrap_err(); let err =
assert!(err.contains("aktuelle Branch")); delete_branch(repo.path.to_string_lossy().to_string(), current, None).unwrap_err();
assert!(err.contains("current branch"));
}
#[test]
fn delete_branch_can_force_delete_unmerged_branch() {
let repo = init_temp_repo("delete_branch_force");
commit_initial_file(&repo.path);
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature/unmerged"]);
fs::write(repo.path.join("feature.txt"), "feature\n")
.expect("feature file should be written");
run_git_test(&repo.path, ["add", "feature.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "feature"]);
run_git_test(&repo.path, ["checkout", "-q", current.as_str()]);
let err = delete_branch(
repo.path.to_string_lossy().to_string(),
"feature/unmerged".to_string(),
Some(false),
)
.unwrap_err();
assert!(err.contains("not fully merged"));
delete_branch(
repo.path.to_string_lossy().to_string(),
"feature/unmerged".to_string(),
Some(true),
)
.unwrap();
assert!(
!ref_exists(&repo.path, "refs/heads/feature/unmerged").unwrap(),
"force-deleted branch should be gone"
);
} }
#[test] #[test]
+23 -8
View File
@@ -1,17 +1,21 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod badge;
mod git; mod git;
use badge::set_sync_badge;
use git::{ use git::{
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history, SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
checkout_branch, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models, checkout_branch, clone_repository, commit, commit_ai_generate, commit_ai_load,
commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head,
create_branch, cred_delete, cred_load, cred_save, delete_branch, compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, delete_branch,
diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, list_branches, diff_file_against_working_tree, fetch, get_file_patch, get_remote_url, get_status,
list_commits, list_file_history, list_repository_files, merge_branch, open_repo_in_explorer, list_branches, list_commits, list_file_history, list_repository_files, list_stashes,
open_repository, open_repository_bundle, open_repository_file, pull, push, read_conflict, merge_branch, open_repo_in_explorer, open_repository, open_repository_bundle,
open_repository_file, pull, push, read_conflict, rebase_abort, rebase_branch, rebase_continue,
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit, rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files, restore_files, restore_to_commit, search_code_introductions, stage_files, stash_apply,
stash_drop, stash_pop, stash_push, unstage_files,
}; };
fn main() { fn main() {
@@ -22,16 +26,22 @@ fn main() {
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
open_repository, open_repository,
clone_repository,
open_repo_in_explorer, open_repo_in_explorer,
open_repository_file, open_repository_file,
get_status, get_status,
list_branches, list_branches,
list_stashes,
checkout_branch, checkout_branch,
create_branch, create_branch,
rename_branch, rename_branch,
delete_branch, delete_branch,
stage_files, stage_files,
unstage_files, unstage_files,
stash_push,
stash_apply,
stash_pop,
stash_drop,
restore_files, restore_files,
get_file_patch, get_file_patch,
apply_file_patch, apply_file_patch,
@@ -42,10 +52,14 @@ fn main() {
commit_ai_generate, commit_ai_generate,
pull, pull,
push, push,
fetch,
list_commits, list_commits,
restore_to_commit, restore_to_commit,
restore_file_from_commit, restore_file_from_commit,
merge_branch, merge_branch,
rebase_branch,
rebase_continue,
rebase_abort,
list_repository_files, list_repository_files,
open_repository_bundle, open_repository_bundle,
list_file_history, list_file_history,
@@ -62,7 +76,8 @@ fn main() {
get_remote_url, get_remote_url,
cred_load, cred_load,
cred_save, cred_save,
cred_delete cred_delete,
set_sync_badge
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "GitLite", "productName": "GitLite",
"version": "2026.7.6", "version": "2026.7.11",
"identifier": "com.git-lite", "identifier": "com.git-lite",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@@ -29,7 +29,7 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["nsis"], "targets": ["nsis"],
"icon": ["icons/icon.ico"], "icon": ["icons/icon.png", "icons/icon.ico"],
"createUpdaterArtifacts": true, "createUpdaterArtifacts": true,
"windows": { "windows": {
"nsis": { "nsis": {
+555 -24
View File
@@ -2,11 +2,13 @@
import { onDestroy, onMount, tick } from "svelte"; import { onDestroy, onMount, tick } from "svelte";
import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater"; import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
import { AlertCircle, BookOpen, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte"; import { AlertCircle, BookOpen, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
import TitleBar from "./lib/TitleBar.svelte"; import TitleBar from "./lib/TitleBar.svelte";
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte"; import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
import BranchPanel from "./lib/components/BranchPanel.svelte"; import BranchPanel from "./lib/components/BranchPanel.svelte";
import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
import CommitPanel from "./lib/components/CommitPanel.svelte"; import CommitPanel from "./lib/components/CommitPanel.svelte";
import CompareDialog from "./lib/components/CompareDialog.svelte"; import CompareDialog from "./lib/components/CompareDialog.svelte";
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte"; import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
@@ -21,11 +23,13 @@
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte"; import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte"; import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
import ResolveDialog from "./lib/components/ResolveDialog.svelte"; import ResolveDialog from "./lib/components/ResolveDialog.svelte";
import StashPanel from "./lib/components/StashPanel.svelte";
import StatusPanel from "./lib/components/StatusPanel.svelte"; import StatusPanel from "./lib/components/StatusPanel.svelte";
import UpdateToast from "./lib/components/UpdateToast.svelte"; import UpdateToast from "./lib/components/UpdateToast.svelte";
import { import {
checkoutBranch, checkoutBranch,
cloneRepository,
commit, commit,
commitAiGenerate, commitAiGenerate,
commitAiLoad, commitAiLoad,
@@ -39,8 +43,10 @@
deleteBranch, deleteBranch,
diffFileAgainstWorkingTree, diffFileAgainstWorkingTree,
compareFileToParent, compareFileToParent,
fetchRemote,
getStatus, getStatus,
listBranches, listBranches,
listStashes,
listCommits, listCommits,
listFileHistory, listFileHistory,
listRepositoryFiles, listRepositoryFiles,
@@ -51,6 +57,9 @@
pull, pull,
push, push,
renameBranch, renameBranch,
rebaseAbort,
rebaseBranch,
rebaseContinue,
getRemoteUrl, getRemoteUrl,
credLoad, credLoad,
credSave, credSave,
@@ -63,7 +72,12 @@
restoreFiles, restoreFiles,
restoreToCommit, restoreToCommit,
searchCodeIntroductions, searchCodeIntroductions,
setSyncBadge,
stageFiles, stageFiles,
stashApply,
stashDrop,
stashPop,
stashPush,
unstageFiles, unstageFiles,
} from "./lib/git"; } from "./lib/git";
@@ -81,6 +95,7 @@
GitFileStatus, GitFileStatus,
GitRepositoryFile, GitRepositoryFile,
GitSearchHit, GitSearchHit,
GitStash,
GitStatus, GitStatus,
LocalModelOption, LocalModelOption,
PatchApplyAction, PatchApplyAction,
@@ -97,6 +112,7 @@
type UpdateToastState = "available" | "downloading" | "installed" | "error"; type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository"; type AppView = "management" | "repository";
type CredentialAction = "push" | "pull" | "fetch" | "clone";
type PendingDiscard = type PendingDiscard =
| { kind: "file"; file: GitFileStatus; staged: boolean } | { kind: "file"; file: GitFileStatus; staged: boolean }
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string }; | { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
@@ -111,13 +127,24 @@
lastOpened: number; lastOpened: number;
} }
interface CloneRequest {
remoteUrl: string;
parentPath: string;
directoryName: string;
}
const OPEN_REPOS_KEY = "gitlite.openRepos.v1"; const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1"; const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1"; const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1"; const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1";
const COMMIT_PANEL_DEFAULT_HEIGHT = 220; const COMMIT_PANEL_DEFAULT_HEIGHT = 220;
const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT; const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT;
const COMMIT_PANEL_MAX_HEIGHT = 640; const COMMIT_PANEL_MAX_HEIGHT = 640;
const HISTORY_ASIDE_DEFAULT_WIDTH = 620;
const HISTORY_ASIDE_MIN_WIDTH = 560;
const HISTORY_ASIDE_MAX_WIDTH = 920;
const ERROR_AUTO_HIDE_MS = 6000;
// ── State ────────────────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────────────────
@@ -127,8 +154,13 @@
let repoTabs: RepoTab[] = []; let repoTabs: RepoTab[] = [];
let recentRepoPaths: string[] = []; let recentRepoPaths: string[] = [];
let repoSearch = ""; let repoSearch = "";
let cloneDialogOpen = false;
let cloneDialogError = "";
let cloneDialogErrorTimer: ReturnType<typeof setTimeout> | undefined;
let pendingClone: CloneRequest | null = null;
let status: GitStatus | null = null; let status: GitStatus | null = null;
let branches: GitBranchInfo[] = []; let branches: GitBranchInfo[] = [];
let stashes: GitStash[] = [];
let commits: GitCommit[] = []; let commits: GitCommit[] = [];
let repoFiles: GitRepositoryFile[] = []; let repoFiles: GitRepositoryFile[] = [];
let selectedExplorerPath = ""; let selectedExplorerPath = "";
@@ -141,6 +173,7 @@
let activeFileHistoryRequestId = ""; let activeFileHistoryRequestId = "";
let lastFileHistoryHeadHash = ""; let lastFileHistoryHeadHash = "";
let commitMessage = ""; let commitMessage = "";
let lastLocalAiGeneratedMessage = "";
let commitAiPhase: CommitAiPhase = "idle"; let commitAiPhase: CommitAiPhase = "idle";
let commitAiGenerating = false; let commitAiGenerating = false;
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined; let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
@@ -154,6 +187,8 @@
let comparison: GitCommitComparison | null = null; let comparison: GitCommitComparison | null = null;
let newBranchCommit: GitCommit | null = null; let newBranchCommit: GitCommit | null = null;
let renameBranchTarget: GitBranchInfo | null = null; let renameBranchTarget: GitBranchInfo | null = null;
let deleteBranchTarget: GitBranchInfo | null = null;
let deleteBranchForce = false;
let compareSelectOpen = false; let compareSelectOpen = false;
let compareDialogOpen = false; let compareDialogOpen = false;
let selectedDiffPath = ""; let selectedDiffPath = "";
@@ -179,12 +214,17 @@
let autoRefreshEnabled = true; let autoRefreshEnabled = true;
let autoRefreshInFlight = false; let autoRefreshInFlight = false;
let credDialogOpen = false; let credDialogOpen = false;
let credDialogAction: "push" | "pull" | null = null; let credDialogAction: CredentialAction | null = null;
let credDialogError = ""; let credDialogError = "";
let credDialogKey: string | null = null; let credDialogKey: string | null = null;
let lastStatusFingerprint = ""; let lastStatusFingerprint = "";
const AUTO_REFRESH_INTERVAL = 4000; const AUTO_REFRESH_INTERVAL = 4000;
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined; let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
const BACKGROUND_FETCH_INTERVAL = 180_000;
const BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS = 30_000;
let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined;
let backgroundFetchInFlight = false;
let lastRepoSwitchAt = 0;
let updateToastOpen = false; let updateToastOpen = false;
let updateToastState: UpdateToastState = "available"; let updateToastState: UpdateToastState = "available";
let pendingUpdate: Update | null = null; let pendingUpdate: Update | null = null;
@@ -195,10 +235,15 @@
let updateCheckInFlight = false; let updateCheckInFlight = false;
let updateDownloadTotal = 0; let updateDownloadTotal = 0;
let updateDownloadedBytes = 0; let updateDownloadedBytes = 0;
let errorAutoHideTimers: Partial<Record<string, ReturnType<typeof setTimeout>>> = {};
let commitPanelHeight = loadCommitPanelHeight(); let commitPanelHeight = loadCommitPanelHeight();
let resizingCommitPanel = false; let resizingCommitPanel = false;
let resizeStartY = 0; let resizeStartY = 0;
let resizeStartHeight = 0; let resizeStartHeight = 0;
let historyAsideWidth = loadHistoryAsideWidth();
let resizingHistoryAside = false;
let historyResizeStartX = 0;
let historyResizeStartWidth = 0;
// ── Derived ──────────────────────────────────────────────────────────────── // ── Derived ────────────────────────────────────────────────────────────────
@@ -206,18 +251,24 @@
$: hasRepository = activeRepoPath.length > 0 && status !== null; $: hasRepository = activeRepoPath.length > 0 && status !== null;
$: workspaceActive = activeView === "repository" && hasRepository; $: workspaceActive = activeView === "repository" && hasRepository;
$: openingRepo = operation === "Opening repository"; $: openingRepo = operation === "Opening repository";
$: cloningRepo = operation === "Cloning repository";
$: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? ""; $: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? "";
$: cloneDisplayName = pendingClone?.directoryName || repoNameFromCloneUrl(pendingClone?.remoteUrl ?? "");
$: changedFiles = status?.files ?? []; $: changedFiles = status?.files ?? [];
$: stagedCount = status?.files.filter((f) => f.staged !== null).length ?? 0; $: stagedCount = status?.files.filter((f) => f.staged !== null).length ?? 0;
$: unstagedCount = status?.files.filter((f) => f.unstaged !== null).length ?? 0; $: unstagedCount = status?.files.filter((f) => f.unstaged !== null).length ?? 0;
$: conflictedFiles = changedFiles.filter((f) => f.staged === "conflicted" || f.unstaged === "conflicted"); $: conflictedFiles = changedFiles.filter((f) => f.staged === "conflicted" || f.unstaged === "conflicted");
$: hasConflicts = conflictedFiles.length > 0; $: hasConflicts = conflictedFiles.length > 0;
$: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !hasConflicts && !isBusy; $: rebaseInProgress = status?.rebase_in_progress ?? false;
$: commitBlockReason = hasConflicts $: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !hasConflicts && !rebaseInProgress && !isBusy;
? `${conflictedFiles.length} ${conflictedFiles.length === 1 ? "merge conflict must" : "merge conflicts must"} be resolved before committing.` $: commitBlockReason = rebaseInProgress
? "A rebase is in progress. Resolve conflicts and use Rebase continue or abort the rebase."
: hasConflicts
? `${conflictedFiles.length} ${conflictedFiles.length === 1 ? "conflict must" : "conflicts must"} be resolved before committing.`
: ""; : "";
$: canCompare = hasRepository && compareFrom.length > 0 && compareTo.length > 0 && compareFrom !== compareTo && !isBusy; $: canCompare = hasRepository && compareFrom.length > 0 && compareTo.length > 0 && compareFrom !== compareTo && !isBusy;
$: localBranches = branches.filter((b) => !b.remote); $: localBranches = branches.filter((b) => !b.remote);
$: localBranchNames = localBranches.map((b) => b.name);
$: remoteBranches = branches.filter((b) => b.remote); $: remoteBranches = branches.filter((b) => b.remote);
$: repoSearchTerm = repoSearch.trim().toLowerCase(); $: repoSearchTerm = repoSearch.trim().toLowerCase();
$: openRepoRows = repoTabs.filter(repoMatchesSearch); $: openRepoRows = repoTabs.filter(repoMatchesSearch);
@@ -234,22 +285,69 @@
onMount(() => { onMount(() => {
loadRepoLists(); loadRepoLists();
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL); autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
backgroundFetchTimer = setInterval(() => { void backgroundFetchTick(); }, BACKGROUND_FETCH_INTERVAL);
void checkForUpdates(); void checkForUpdates();
void initCommitAi(); void initCommitAi();
}); });
onDestroy(() => { onDestroy(() => {
if (autoRefreshTimer) clearInterval(autoRefreshTimer); if (autoRefreshTimer) clearInterval(autoRefreshTimer);
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
if (commitAiPollTimer) clearInterval(commitAiPollTimer); if (commitAiPollTimer) clearInterval(commitAiPollTimer);
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
Object.values(errorAutoHideTimers).forEach((timer) => {
if (timer) clearTimeout(timer);
});
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId); if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
}); });
$: scheduleAutoHideError("errorMessage", errorMessage, (message) => {
if (errorMessage === message) errorMessage = "";
});
$: scheduleAutoHideError("linePatchError", linePatchError, (message) => {
if (linePatchError === message) linePatchError = "";
});
$: scheduleAutoHideError("globalSearchError", globalSearchError, (message) => {
if (globalSearchError === message) globalSearchError = "";
});
$: scheduleAutoHideError("credDialogError", credDialogError, (message) => {
if (credDialogError === message) credDialogError = "";
});
$: scheduleAutoHideError("updateError", updateError, (message) => {
if (updateError === message) updateError = "";
if (updateToastState === "error") updateToastOpen = false;
});
$: scheduleAutoHideError(
"updateErrorToast",
updateToastOpen && updateToastState === "error" ? (updateError || "Update failed") : "",
() => {
if (updateToastState === "error") updateToastOpen = false;
},
);
// ── Auto-refresh ─────────────────────────────────────────────────────────── // ── Auto-refresh ───────────────────────────────────────────────────────────
function statusFingerprint(value: GitStatus): string { function statusFingerprint(value: GitStatus): string {
return JSON.stringify({ branch: value.current_branch, upstream: value.upstream, ahead: value.ahead, behind: value.behind, files: value.files }); return JSON.stringify({ branch: value.current_branch, upstream: value.upstream, ahead: value.ahead, behind: value.behind, files: value.files });
} }
// Silent background fetch (every 180s): only updates the local remote-tracking ref so
// ahead/behind (and the taskbar badge) stay accurate without the user pulling manually.
// Errors are swallowed here — auth/network failures surface via the manual Fetch/Pull/
// Push buttons instead, not as a background popup.
async function backgroundFetchTick() {
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || backgroundFetchInFlight) return;
if (Date.now() - lastRepoSwitchAt < BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) return;
backgroundFetchInFlight = true;
try {
await fetchRemote(activeRepoPath);
} catch {
// ignore — see comment above
} finally {
backgroundFetchInFlight = false;
}
}
async function autoRefreshTick() { async function autoRefreshTick() {
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return; if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
autoRefreshInFlight = true; autoRefreshInFlight = true;
@@ -262,6 +360,7 @@
const bundle = await openRepositoryBundle(activeRepoPath, 100); const bundle = await openRepositoryBundle(activeRepoPath, 100);
const previousHeadHash = lastFileHistoryHeadHash; const previousHeadHash = lastFileHistoryHeadHash;
await refreshBranchList(activeRepoPath, bundle.branches); await refreshBranchList(activeRepoPath, bundle.branches);
await refreshStashes(activeRepoPath, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits); await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files); await refreshExplorerFiles(activeRepoPath, bundle.files);
// File history reflects `git log`, which only changes when HEAD actually moves // File history reflects `git log`, which only changes when HEAD actually moves
@@ -322,6 +421,13 @@
startCommitAiPolling(); startCommitAiPolling();
} }
function updateCommitMessage(message: string) {
commitMessage = message;
if (message !== lastLocalAiGeneratedMessage) {
lastLocalAiGeneratedMessage = "";
}
}
async function generateCommitMessageWithAi() { async function generateCommitMessageWithAi() {
if (!activeRepoPath || commitAiGenerating) return; if (!activeRepoPath || commitAiGenerating) return;
if (aiSettings.provider === "local" && commitAiPhase !== "ready") return; if (aiSettings.provider === "local" && commitAiPhase !== "ready") return;
@@ -330,7 +436,13 @@
try { try {
const notes = commitMessage.trim() || undefined; const notes = commitMessage.trim() || undefined;
if (aiSettings.provider === "local") { if (aiSettings.provider === "local") {
commitMessage = await commitAiGenerate(activeRepoPath, { provider: "local", notes }); const localNotes = notes && notes !== lastLocalAiGeneratedMessage ? notes : undefined;
commitMessage = await commitAiGenerate(activeRepoPath, {
provider: "local",
notes: localNotes,
localProfile: aiSettings.localProfile,
});
lastLocalAiGeneratedMessage = commitMessage;
} else if (aiSettings.provider === "openai") { } else if (aiSettings.provider === "openai") {
const cred = await credLoad("ai:openai"); const cred = await credLoad("ai:openai");
commitMessage = await commitAiGenerate(activeRepoPath, { commitMessage = await commitAiGenerate(activeRepoPath, {
@@ -339,6 +451,7 @@
model: aiSettings.openaiModel, model: aiSettings.openaiModel,
apiKey: cred?.password, apiKey: cred?.password,
}); });
lastLocalAiGeneratedMessage = "";
} else if (aiSettings.provider === "anthropic") { } else if (aiSettings.provider === "anthropic") {
const cred = await credLoad("ai:anthropic"); const cred = await credLoad("ai:anthropic");
commitMessage = await commitAiGenerate(activeRepoPath, { commitMessage = await commitAiGenerate(activeRepoPath, {
@@ -347,6 +460,7 @@
model: aiSettings.anthropicModel, model: aiSettings.anthropicModel,
apiKey: cred?.password, apiKey: cred?.password,
}); });
lastLocalAiGeneratedMessage = "";
} else { } else {
const cred = await credLoad("ai:custom"); const cred = await credLoad("ai:custom");
commitMessage = await commitAiGenerate(activeRepoPath, { commitMessage = await commitAiGenerate(activeRepoPath, {
@@ -356,6 +470,7 @@
baseUrl: aiSettings.customBaseUrl, baseUrl: aiSettings.customBaseUrl,
apiKey: cred?.password, apiKey: cred?.password,
}); });
lastLocalAiGeneratedMessage = "";
} }
} catch (error) { } catch (error) {
errorMessage = errorToMessage(error); errorMessage = errorToMessage(error);
@@ -447,6 +562,41 @@
return path.split(/[\\/]/).filter(Boolean).pop() ?? path; return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
} }
function repoNameFromCloneUrl(url: string): string {
const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? "";
const lastSegment = trimmed.split(/[\\/:]/).filter(Boolean).pop() ?? "";
return lastSegment.replace(/\.git$/i, "").trim();
}
function setCloneDialogError(message: string) {
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
cloneDialogError = message;
if (message) {
cloneDialogErrorTimer = setTimeout(() => {
if (cloneDialogError === message) cloneDialogError = "";
}, ERROR_AUTO_HIDE_MS);
}
}
function scheduleAutoHideError(
key: string,
message: string,
clearIfCurrent: (message: string) => void,
) {
const existing = errorAutoHideTimers[key];
if (existing) {
clearTimeout(existing);
delete errorAutoHideTimers[key];
}
if (!message) return;
errorAutoHideTimers[key] = setTimeout(() => {
clearIfCurrent(message);
delete errorAutoHideTimers[key];
}, ERROR_AUTO_HIDE_MS);
}
function repoKey(path: string): string { function repoKey(path: string): string {
return path.replace(/\\/g, "/").trim().toLowerCase(); return path.replace(/\\/g, "/").trim().toLowerCase();
} }
@@ -526,8 +676,9 @@
function defaultAiSettings(): AiSettings { function defaultAiSettings(): AiSettings {
return { return {
provider: "local", provider: "openai",
localModelId: "qwen2.5-1.5b", localModelId: "qwen2.5-0.5b",
localProfile: "fast",
openaiModel: "gpt-4o-mini", openaiModel: "gpt-4o-mini",
anthropicModel: "claude-3-5-haiku-latest", anthropicModel: "claude-3-5-haiku-latest",
customBaseUrl: "", customBaseUrl: "",
@@ -539,7 +690,11 @@
try { try {
const stored = JSON.parse(localStorage.getItem(AI_SETTINGS_KEY) ?? "null") as unknown; const stored = JSON.parse(localStorage.getItem(AI_SETTINGS_KEY) ?? "null") as unknown;
if (stored && typeof stored === "object") { if (stored && typeof stored === "object") {
return { ...defaultAiSettings(), ...(stored as Partial<AiSettings>) }; const merged = { ...defaultAiSettings(), ...(stored as Partial<AiSettings>) };
// Local AI is still in development and disabled in the settings UI — migrate any
// previously saved selection away from it so nobody gets stuck on a dead option.
if (merged.provider === "local") merged.provider = "openai";
return merged;
} }
} catch { } catch {
// Fall through to defaults below. // Fall through to defaults below.
@@ -577,6 +732,28 @@
} }
} }
function clampHistoryAsideWidth(value: number): number {
return Math.min(HISTORY_ASIDE_MAX_WIDTH, Math.max(HISTORY_ASIDE_MIN_WIDTH, Math.round(value)));
}
function loadHistoryAsideWidth(): number {
try {
const stored = Number(localStorage.getItem(HISTORY_ASIDE_WIDTH_KEY));
if (Number.isFinite(stored) && stored > 0) return clampHistoryAsideWidth(stored);
} catch {
// Fall through to the default below.
}
return HISTORY_ASIDE_DEFAULT_WIDTH;
}
function persistHistoryAsideWidth(value: number) {
try {
localStorage.setItem(HISTORY_ASIDE_WIDTH_KEY, String(value));
} catch {
// Local storage is best-effort only; resizing must keep working without it.
}
}
function startCommitPanelResize(event: PointerEvent) { function startCommitPanelResize(event: PointerEvent) {
event.preventDefault(); event.preventDefault();
resizingCommitPanel = true; resizingCommitPanel = true;
@@ -605,6 +782,34 @@
persistCommitPanelHeight(commitPanelHeight); persistCommitPanelHeight(commitPanelHeight);
} }
function startHistoryAsideResize(event: PointerEvent) {
event.preventDefault();
resizingHistoryAside = true;
historyResizeStartX = event.clientX;
historyResizeStartWidth = historyAsideWidth;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function onHistoryAsideResizeMove(event: PointerEvent) {
if (!resizingHistoryAside) return;
historyAsideWidth = clampHistoryAsideWidth(historyResizeStartWidth + (historyResizeStartX - event.clientX));
}
function endHistoryAsideResize(event: PointerEvent) {
if (!resizingHistoryAside) return;
resizingHistoryAside = false;
persistHistoryAsideWidth(historyAsideWidth);
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
function onHistoryAsideResizeKeydown(event: KeyboardEvent) {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
historyAsideWidth = clampHistoryAsideWidth(historyAsideWidth + (event.key === "ArrowLeft" ? 24 : -24));
persistHistoryAsideWidth(historyAsideWidth);
}
function rememberRecentRepo(path: string) { function rememberRecentRepo(path: string) {
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40); recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
persistRepoLists(); persistRepoLists();
@@ -634,8 +839,10 @@
repoPath = ""; repoPath = "";
status = null; status = null;
lastStatusFingerprint = ""; lastStatusFingerprint = "";
void setSyncBadge(0, 0, 0).catch(() => {});
} }
branches = []; branches = [];
stashes = [];
commits = []; commits = [];
lastFileHistoryHeadHash = ""; lastFileHistoryHeadHash = "";
repoFiles = []; repoFiles = [];
@@ -653,6 +860,8 @@
pendingRestoreFile = null; pendingRestoreFile = null;
newBranchCommit = null; newBranchCommit = null;
globalSearchResults = []; globalSearchResults = [];
deleteBranchTarget = null;
deleteBranchForce = false;
globalSearchOpen = false; globalSearchOpen = false;
globalSearchError = ""; globalSearchError = "";
resolveDialogOpen = false; resolveDialogOpen = false;
@@ -667,6 +876,7 @@
repoPath = activeRepoPath; repoPath = activeRepoPath;
lastStatusFingerprint = statusFingerprint(nextStatus); lastStatusFingerprint = statusFingerprint(nextStatus);
upsertRepoTab(activeRepoPath, nextStatus); upsertRepoTab(activeRepoPath, nextStatus);
void setSyncBadge(nextStatus.ahead, nextStatus.behind, nextStatus.files.length).catch(() => {});
} }
function errorToMessage(error: unknown): string { function errorToMessage(error: unknown): string {
@@ -687,6 +897,11 @@
return (value?.files ?? []).some((file) => file.staged === "conflicted" || file.unstaged === "conflicted"); return (value?.files ?? []).some((file) => file.staged === "conflicted" || file.unstaged === "conflicted");
} }
function isBranchNotFullyMergedError(message: string): boolean {
const value = message.toLowerCase();
return value.includes("not fully merged") || value.includes("run 'git branch -d'");
}
async function runOperation(label: string, task: () => Promise<void>) { async function runOperation(label: string, task: () => Promise<void>) {
if (isBusy) return; if (isBusy) return;
operation = label; operation = label;
@@ -726,6 +941,10 @@
branches = prefetched ?? (await listBranches(path)); branches = prefetched ?? (await listBranches(path));
} }
async function refreshStashes(path = activeRepoPath, prefetched?: GitStash[]) {
stashes = prefetched ?? (await listStashes(path));
}
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) { async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
commits = prefetched ?? (await listCommits(path, 100)); commits = prefetched ?? (await listCommits(path, 100));
lastFileHistoryHeadHash = commits[0]?.hash ?? ""; lastFileHistoryHeadHash = commits[0]?.hash ?? "";
@@ -820,8 +1039,10 @@
if (globalSearchBusy) void cancelGlobalSearch(); if (globalSearchBusy) void cancelGlobalSearch();
activeView = "repository"; activeView = "repository";
await refreshBranchList(activeRepoPath, bundle.branches); await refreshBranchList(activeRepoPath, bundle.branches);
await refreshStashes(activeRepoPath, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits); await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files); await refreshExplorerFiles(activeRepoPath, bundle.files);
lastRepoSwitchAt = Date.now();
}); });
} }
@@ -842,6 +1063,91 @@
} }
} }
async function cloneRepo(
remoteUrl: string,
parentPath: string,
directoryName: string,
username?: string,
password?: string,
key?: string | null,
fromStore = false,
) {
if (isBusy) return;
if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; }
if (!parentPath) { errorMessage = "Select a destination folder."; return; }
const request: CloneRequest = { remoteUrl, parentPath, directoryName };
pendingClone = request;
const credentialKey = key === undefined ? orgKeyFromUrl(remoteUrl) : key;
if (!username && !password) {
const stored = await loadStoredCredential(credentialKey);
if (stored && !isCredentialExpired(stored)) {
await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true);
return;
}
if (stored && credentialKey) await credDelete(credentialKey).catch(() => {});
}
operation = "Cloning repository";
errorMessage = "";
setCloneDialogError("");
try {
const bundle = await cloneRepository(
remoteUrl,
parentPath,
directoryName || undefined,
username,
password,
100,
);
resetRepositoryState(false);
applyStatus(bundle.status);
if (globalSearchBusy) void cancelGlobalSearch();
activeView = "repository";
await refreshBranchList(activeRepoPath, bundle.branches);
await refreshStashes(activeRepoPath, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
cloneDialogOpen = false;
pendingClone = null;
if (credDialogAction === "clone") {
credDialogOpen = false;
credDialogAction = null;
credDialogError = "";
credDialogKey = null;
}
lastRepoSwitchAt = Date.now();
} catch (error) {
const rawMessage = errorToMessage(error);
const message = stripAuthPrefix(rawMessage);
if (isAuthError(rawMessage)) {
errorMessage = "";
setCloneDialogError("");
if (fromStore) {
if (credentialKey) void credDelete(credentialKey).catch(() => {});
credDialogError = "Credentials were rejected or have expired. Please sign in again.";
} else {
credDialogError = message || "Sign-in is required to clone this repository.";
}
credDialogAction = "clone";
credDialogKey = credentialKey;
credDialogOpen = true;
} else {
setCloneDialogError(message);
errorMessage = "";
}
} finally {
operation = "";
}
}
function openCloneDialog() {
if (isBusy) return;
setCloneDialogError("");
cloneDialogOpen = true;
}
function openRepoManagement() { function openRepoManagement() {
if (isBusy) return; if (isBusy) return;
activeView = "management"; activeView = "management";
@@ -896,6 +1202,7 @@
await runOperation("Refreshing", async () => { await runOperation("Refreshing", async () => {
applyStatus(await getStatus(activeRepoPath)); applyStatus(await getStatus(activeRepoPath));
await refreshBranchList(activeRepoPath); await refreshBranchList(activeRepoPath);
await refreshStashes(activeRepoPath);
await refreshCommitHistory(activeRepoPath); await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath); await refreshFileHistory(activeRepoPath);
@@ -952,16 +1259,41 @@
return; return;
} }
const confirmed = window.confirm(`Delete local branch "${branch.name}"?\n\nGit will refuse if the branch has unmerged changes.`); deleteBranchTarget = branch;
if (!confirmed) return; deleteBranchForce = false;
}
await runOperation(`Deleting ${branch.name}`, async () => { async function confirmDeleteBranch() {
applyStatus(await deleteBranch(activeRepoPath, branch.name)); const branch = deleteBranchTarget;
if (!activeRepoPath || !branch || branch.remote || branch.current || isBusy) return;
operation = `${deleteBranchForce ? "Force deleting" : "Deleting"} ${branch.name}`;
errorMessage = "";
try {
applyStatus(await deleteBranch(activeRepoPath, branch.name, deleteBranchForce));
deleteBranchTarget = null;
deleteBranchForce = false;
await refreshBranchList(activeRepoPath); await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath); await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath); await refreshFileHistory(activeRepoPath);
}); } catch (error) {
const message = errorToMessage(error);
if (deleteBranchForce || !isBranchNotFullyMergedError(message)) {
errorMessage = message;
return;
}
deleteBranchForce = true;
} finally {
operation = "";
}
}
function closeDeleteBranchDialog() {
if (isBusy) return;
deleteBranchTarget = null;
deleteBranchForce = false;
} }
function openNewBranchDialog(commit: GitCommit) { function openNewBranchDialog(commit: GitCommit) {
@@ -994,6 +1326,46 @@
}); });
} }
async function rebaseOnto(branch: GitBranchInfo) {
if (!activeRepoPath || branch.current || rebaseInProgress) return;
await runOperation(`Rebasing onto ${branch.name}`, async () => {
applyStatus(await rebaseBranch(activeRepoPath, branch.name));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function continueRebase() {
if (!activeRepoPath || !rebaseInProgress || hasConflicts) return;
await runOperation("Continuing rebase", async () => {
applyStatus(await rebaseContinue(activeRepoPath));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function abortRebase() {
if (!activeRepoPath || !rebaseInProgress) return;
const confirmed = window.confirm("Abort the current rebase and return to the previous state?");
if (!confirmed) return;
await runOperation("Aborting rebase", async () => {
applyStatus(await rebaseAbort(activeRepoPath));
preparedResolutions = {};
resolveDialogOpen = false;
conflict = null;
conflictTarget = "";
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
// Resolve the keychain key (host/org) for the active repo's remote. // Resolve the keychain key (host/org) for the active repo's remote.
async function currentCredKey(): Promise<string | null> { async function currentCredKey(): Promise<string | null> {
if (!activeRepoPath) return null; if (!activeRepoPath) return null;
@@ -1014,17 +1386,17 @@
} }
} }
async function openCredentialDialog(action: "push" | "pull", key?: string | null) { async function openCredentialDialog(action: CredentialAction, key?: string | null) {
if (!activeRepoPath) return; if (!activeRepoPath && action !== "clone") return;
credDialogError = ""; credDialogError = "";
credDialogAction = action; credDialogAction = action;
credDialogKey = key === undefined ? await currentCredKey() : key; credDialogKey = key === undefined && action !== "clone" ? await currentCredKey() : (key ?? null);
credDialogOpen = true; credDialogOpen = true;
} }
// Post-process a pull/push result: surface errors, and on rejected/expired // Post-process a pull/push result: surface errors, and on rejected/expired
// credentials drop the stored entry and re-open the login dialog. // credentials drop the stored entry and re-open the login dialog.
function handleRemoteResult(action: "push" | "pull", key: string | null, fromStore: boolean) { function handleRemoteResult(action: "push" | "pull" | "fetch", key: string | null, fromStore: boolean) {
if (!errorMessage) { if (!errorMessage) {
credDialogOpen = false; credDialogOpen = false;
credDialogAction = null; credDialogAction = null;
@@ -1068,6 +1440,19 @@
handleRemoteResult("pull", key, fromStore); handleRemoteResult("pull", key, fromStore);
} }
async function doActualFetch(
username: string,
password: string,
key: string | null,
fromStore: boolean,
) {
errorMessage = "";
await runOperation("Fetching", async () => {
applyStatus(await fetchRemote(activeRepoPath, username, password));
});
handleRemoteResult("fetch", key, fromStore);
}
async function doActualPush( async function doActualPush(
username: string, username: string,
password: string, password: string,
@@ -1137,6 +1522,18 @@
const key = credDialogKey; const key = credDialogKey;
if (credDialogAction === "pull") await doActualPull(username, password, key, false); if (credDialogAction === "pull") await doActualPull(username, password, key, false);
else if (credDialogAction === "push") await doActualPush(username, password, key, false); else if (credDialogAction === "push") await doActualPush(username, password, key, false);
else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false);
else if (credDialogAction === "clone" && pendingClone) {
await cloneRepo(
pendingClone.remoteUrl,
pendingClone.parentPath,
pendingClone.directoryName,
username,
password,
key,
false,
);
}
// Only persist once the operation actually succeeded (dialog has closed). // Only persist once the operation actually succeeded (dialog has closed).
if (!credDialogOpen && save && key) { if (!credDialogOpen && save && key) {
@@ -1148,13 +1545,14 @@
} }
} }
async function startRemoteAction(action: "push" | "pull") { async function startRemoteAction(action: "push" | "pull" | "fetch") {
if (!activeRepoPath) return; if (!activeRepoPath) return;
const key = await currentCredKey(); const key = await currentCredKey();
const stored = await loadStoredCredential(key); const stored = await loadStoredCredential(key);
if (stored && !isCredentialExpired(stored)) { if (stored && !isCredentialExpired(stored)) {
if (action === "pull") await doActualPull(stored.username, stored.password, key, true); if (action === "pull") await doActualPull(stored.username, stored.password, key, true);
else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true);
else await doActualPush(stored.username, stored.password, key, true); else await doActualPush(stored.username, stored.password, key, true);
return; return;
} }
@@ -1164,6 +1562,10 @@
await openCredentialDialog(action, key); await openCredentialDialog(action, key);
} }
async function fetchRepo() {
await startRemoteAction("fetch");
}
async function pullRepo() { async function pullRepo() {
await startRemoteAction("pull"); await startRemoteAction("pull");
} }
@@ -1172,6 +1574,47 @@
await startRemoteAction("push"); await startRemoteAction("push");
} }
async function saveStash(message: string, includeUntracked: boolean) {
if (!activeRepoPath || changedFiles.length === 0) return;
await runOperation("Stashing changes", async () => {
applyStatus(await stashPush(activeRepoPath, message, includeUntracked));
await refreshStashes(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function applyStashEntry(stash: GitStash) {
if (!activeRepoPath) return;
await runOperation(`Applying ${stash.selector}`, async () => {
applyStatus(await stashApply(activeRepoPath, stash.selector));
await refreshStashes(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function popStashEntry(stash: GitStash) {
if (!activeRepoPath) return;
await runOperation(`Popping ${stash.selector}`, async () => {
applyStatus(await stashPop(activeRepoPath, stash.selector));
await refreshStashes(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function dropStashEntry(stash: GitStash) {
if (!activeRepoPath) return;
const confirmed = window.confirm(`Delete ${stash.selector}?\n\n"${stash.message || stash.selector}"`);
if (!confirmed) return;
await runOperation(`Dropping ${stash.selector}`, async () => {
applyStatus(await stashDrop(activeRepoPath, stash.selector));
await refreshStashes(activeRepoPath);
});
}
// ── File staging / restore ───────────────────────────────────────────────── // ── File staging / restore ─────────────────────────────────────────────────
async function stageFile(file: GitFileStatus) { async function stageFile(file: GitFileStatus) {
@@ -1333,12 +1776,17 @@
const message = commitMessage.trim(); const message = commitMessage.trim();
if (!message || !activeRepoPath) return; if (!message || !activeRepoPath) return;
if (hasConflicts) { if (hasConflicts) {
errorMessage = "Resolve all merge conflicts before committing."; errorMessage = "Resolve all conflicts before committing.";
return;
}
if (rebaseInProgress) {
errorMessage = "A rebase is in progress. Use Rebase continue or abort the rebase.";
return; return;
} }
await runOperation("Committing", async () => { await runOperation("Committing", async () => {
applyStatus(await commit(activeRepoPath, message)); applyStatus(await commit(activeRepoPath, message));
commitMessage = ""; commitMessage = "";
lastLocalAiGeneratedMessage = "";
await refreshBranchList(activeRepoPath); await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath); await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
@@ -1644,6 +2092,7 @@
else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog(); else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null; else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null; else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog();
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false; else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog(); else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
} }
@@ -1670,6 +2119,7 @@
{operation} {operation}
{autoRefreshEnabled} {autoRefreshEnabled}
{autoRefreshInFlight} {autoRefreshInFlight}
onFetch={fetchRepo}
onPull={pullRepo} onPull={pullRepo}
onPush={pushRepo} onPush={pushRepo}
onRefresh={refreshRepo} onRefresh={refreshRepo}
@@ -1751,14 +2201,33 @@
</section> </section>
{/if} {/if}
{#if workspaceActive && hasConflicts} {#if workspaceActive && hasConflicts && !rebaseInProgress}
<section class="notice conflict" role="alert"> <section class="notice conflict" role="alert">
<GitMerge size={17} aria-hidden="true" /> <GitMerge size={17} aria-hidden="true" />
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} merge conflicts.</span> <span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} conflicts.</span>
<button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button> <button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>
</section> </section>
{/if} {/if}
{#if workspaceActive && rebaseInProgress}
<section class="notice rebase" role="status">
<GitBranch size={17} aria-hidden="true" />
<span>
Rebase in progress.
{#if hasConflicts}
Resolve conflicts, then continue.
{:else}
Continue when the index is ready, or abort to return to the previous state.
{/if}
</span>
{#if hasConflicts}
<button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>
{/if}
<button type="button" onclick={continueRebase} disabled={isBusy || hasConflicts}>Continue</button>
<button type="button" onclick={abortRebase} disabled={isBusy}>Abort</button>
</section>
{/if}
{#if activeView === "management"} {#if activeView === "management"}
<section class="repo-management" aria-label="Repository Management"> <section class="repo-management" aria-label="Repository Management">
<div class="repo-management-head"> <div class="repo-management-head">
@@ -1767,6 +2236,10 @@
<h1>Repositories</h1> <h1>Repositories</h1>
</div> </div>
<div class="repo-management-actions"> <div class="repo-management-actions">
<button class="btn-primary" type="button" onclick={openCloneDialog} disabled={isBusy}>
<Download size={15} aria-hidden="true" />
Clone
</button>
<button class="btn-secondary" type="button" onclick={chooseRepositoryFolder} disabled={isBusy}> <button class="btn-secondary" type="button" onclick={chooseRepositoryFolder} disabled={isBusy}>
<FolderOpen size={15} aria-hidden="true" /> <FolderOpen size={15} aria-hidden="true" />
Browse Browse
@@ -1873,7 +2346,7 @@
</section> </section>
{:else} {:else}
<!-- Workspace --> <!-- Workspace -->
<section class="workspace" aria-label="Git workspace"> <section class="workspace" aria-label="Git workspace" style="--history-aside-width: {historyAsideWidth}px;">
<!-- Left sidebar: branches + explorer --> <!-- Left sidebar: branches + explorer -->
<aside class="left-sidebar" aria-label="Repository navigation"> <aside class="left-sidebar" aria-label="Repository navigation">
@@ -1885,10 +2358,21 @@
{isBusy} {isBusy}
onCheckout={checkout} onCheckout={checkout}
onMerge={merge} onMerge={merge}
onRebase={rebaseOnto}
onCreateBranch={createNewBranch} onCreateBranch={createNewBranch}
onRenameBranch={renameLocalBranch} onRenameBranch={renameLocalBranch}
onDeleteBranch={deleteLocalBranch} onDeleteBranch={deleteLocalBranch}
/> />
<StashPanel
{stashes}
changedCount={changedFiles.length}
{hasRepository}
{isBusy}
onPush={saveStash}
onApply={applyStashEntry}
onPop={popStashEntry}
onDrop={dropStashEntry}
/>
<ExplorerPanel <ExplorerPanel
{repoFiles} {repoFiles}
{expandedExplorerPaths} {expandedExplorerPaths}
@@ -1968,7 +2452,7 @@
{commitAiPhase} {commitAiPhase}
{commitAiGenerating} {commitAiGenerating}
onCommit={commitChanges} onCommit={commitChanges}
onCommitMessageChange={(msg) => { commitMessage = msg; }} onCommitMessageChange={updateCommitMessage}
onGenerateCommitMessage={generateCommitMessageWithAi} onGenerateCommitMessage={generateCommitMessageWithAi}
onOpenAiSettings={() => { aiSettingsOpen = true; }} onOpenAiSettings={() => { aiSettingsOpen = true; }}
/> />
@@ -1977,8 +2461,29 @@
<!-- Right sidebar: commit graph + file history --> <!-- Right sidebar: commit graph + file history -->
<aside class="history-aside" aria-label="Commit history"> <aside class="history-aside" aria-label="Commit history">
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="history-resize-handle"
class:resizing={resizingHistoryAside}
role="separator"
aria-orientation="vertical"
aria-label="Resize history panel width"
aria-valuenow={historyAsideWidth}
aria-valuemin={HISTORY_ASIDE_MIN_WIDTH}
aria-valuemax={HISTORY_ASIDE_MAX_WIDTH}
tabindex="0"
onpointerdown={startHistoryAsideResize}
onpointermove={onHistoryAsideResizeMove}
onpointerup={endHistoryAsideResize}
onpointercancel={endHistoryAsideResize}
onkeydown={onHistoryAsideResizeKeydown}
></div>
<HistoryPanel <HistoryPanel
{commits} {commits}
{localBranchNames}
activeBranch={status?.current_branch ?? ""}
repositoryKey={activeRepoPath}
{hasRepository} {hasRepository}
{isBusy} {isBusy}
{expandedCommitHashes} {expandedCommitHashes}
@@ -2085,6 +2590,17 @@
/> />
{/if} {/if}
<!-- Delete a local branch from the branch context menu -->
{#if deleteBranchTarget}
<BranchDeleteConfirmDialog
branch={deleteBranchTarget}
force={deleteBranchForce}
{isBusy}
onConfirm={confirmDeleteBranch}
onClose={closeDeleteBranchDialog}
/>
{/if}
<!-- Choose the AI provider/model used to generate commit messages --> <!-- Choose the AI provider/model used to generate commit messages -->
{#if aiSettingsOpen} {#if aiSettingsOpen}
<AiSettingsDialog <AiSettingsDialog
@@ -2136,11 +2652,26 @@
/> />
{/if} {/if}
<!-- Clone repository dialog -->
{#if cloneDialogOpen}
<CloneRepositoryDialog
isBusy={operation === "Cloning repository"}
error={cloneDialogError}
onClone={cloneRepo}
onClose={() => { if (!isBusy) cloneDialogOpen = false; }}
/>
{/if}
<!-- Full-screen overlay while a repository is being opened --> <!-- Full-screen overlay while a repository is being opened -->
{#if openingRepo} {#if openingRepo}
<RepoLoadingOverlay repoName={repoDisplayName} /> <RepoLoadingOverlay repoName={repoDisplayName} />
{/if} {/if}
<!-- Full-screen overlay while a repository is being cloned -->
{#if cloningRepo}
<RepoLoadingOverlay label="Cloning repository" repoName={cloneDisplayName} />
{/if}
<!-- Conflict resolve dialog --> <!-- Conflict resolve dialog -->
{#if resolveDialogOpen} {#if resolveDialogOpen}
<ResolveDialog <ResolveDialog
+810 -27
View File
@@ -187,6 +187,199 @@
} }
.section-head h2 { margin: 1px 0 0; color: var(--color-ink); font-size: 14px; line-height: 1.2; font-weight: 600; } .section-head h2 { margin: 1px 0 0; color: var(--color-ink); font-size: 14px; line-height: 1.2; font-weight: 600; }
.section-head-actions {
display: flex;
align-items: center;
flex: 0 0 auto;
justify-content: flex-end;
gap: 8px;
min-width: 0;
}
.graph-branch-dialog-button {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 26px;
padding: 0 9px;
overflow: hidden;
border: 1px solid rgba(91,209,138,0.24);
border-radius: 999px;
color: #b6f1c4;
background: rgba(34,68,48,0.28);
font-size: 11px;
font-weight: 800;
white-space: nowrap;
transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
}
.graph-branch-dialog-button:hover {
border-color: rgba(91,209,138,0.42);
background: rgba(34,68,48,0.42);
}
.graph-branch-dialog-button span {
padding: 1px 5px;
border-radius: 999px;
color: #061021;
background: #6ce18f;
font-size: 10px;
line-height: 1.2;
}
.branch-filter-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 24px;
background: rgba(4,8,18,0.58);
backdrop-filter: blur(8px);
}
.branch-filter-dialog {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
width: min(520px, 100%);
max-height: min(680px, calc(100vh - 48px));
overflow: hidden;
border: 1px solid rgba(94,110,156,0.24);
border-radius: 10px;
background:
linear-gradient(180deg, rgba(255,255,255,0.045), transparent 70%),
var(--color-surface);
box-shadow: 0 24px 80px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.05);
}
.branch-filter-dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 14px 12px;
border-bottom: 1px solid var(--color-border-subtle);
background: linear-gradient(90deg, rgba(100,108,255,0.12), rgba(65,209,255,0.04));
}
.branch-filter-dialog-head h3 {
margin: 1px 0 0;
color: var(--color-ink);
font-size: 16px;
font-weight: 750;
}
.dialog-icon-button {
display: inline-grid;
place-items: center;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid rgba(94,110,156,0.18);
border-radius: 7px;
color: var(--color-ink-dim);
background: rgba(255,255,255,0.035);
}
.dialog-icon-button:hover {
color: var(--color-ink);
border-color: rgba(65,209,255,0.28);
background: rgba(65,209,255,0.08);
}
.branch-filter-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--color-border-subtle);
color: var(--color-ink-dim);
font-size: 12px;
font-weight: 700;
}
.branch-filter-actions {
display: inline-flex;
gap: 6px;
}
.branch-filter-actions button {
min-height: 24px;
padding: 0 8px;
border-color: rgba(94,110,156,0.16);
border-radius: 999px;
color: var(--color-ink-dim);
background: rgba(255,255,255,0.035);
font-size: 10.5px;
font-weight: 800;
}
.branch-filter-actions button:hover:not(:disabled) {
border-color: rgba(65,209,255,0.28);
color: var(--color-ink);
background: rgba(65,209,255,0.08);
}
.branch-filter-actions button:disabled {
cursor: default;
opacity: 0.45;
}
.branch-filter-dialog-list {
display: grid;
align-content: start;
gap: 4px;
min-height: 0;
padding: 8px;
overflow: auto;
scrollbar-width: thin;
}
.branch-filter-option {
display: grid;
grid-template-columns: 16px 16px minmax(0, 1fr);
align-items: center;
gap: 8px;
min-height: 32px;
padding: 0 9px;
border: 1px solid transparent;
border-radius: 7px;
color: #a8eeba;
background: transparent;
cursor: pointer;
font-size: 12px;
font-weight: 750;
}
.branch-filter-option:hover {
border-color: rgba(91,209,138,0.18);
background: rgba(34,68,48,0.22);
}
.branch-filter-option.muted {
color: var(--color-ink-faint);
opacity: 0.72;
}
.branch-filter-option input {
width: 14px;
height: 14px;
accent-color: #6ce18f;
}
.branch-filter-option svg {
color: currentColor;
}
.branch-filter-option span {
min-width: 0;
overflow: hidden;
font-family: var(--font-mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.eyebrow { .eyebrow {
display: block; display: block;
color: var(--color-ink-faint); color: var(--color-ink-faint);
@@ -669,6 +862,8 @@
.notice.error { border-color: rgba(232,96,90,0.3); color: #f09090; background: rgba(232,96,90,0.08); } .notice.error { border-color: rgba(232,96,90,0.3); color: #f09090; background: rgba(232,96,90,0.08); }
.notice.busy { border-color: rgba(90,140,248,0.28); color: #8ab0f8; background: rgba(90,140,248,0.07); } .notice.busy { border-color: rgba(90,140,248,0.28); color: #8ab0f8; background: rgba(90,140,248,0.07); }
.notice.conflict { border-color: rgba(224,160,64,0.3); color: #e8b060; background: rgba(224,160,64,0.07); } .notice.conflict { border-color: rgba(224,160,64,0.3); color: #e8b060; background: rgba(224,160,64,0.07); }
.notice.rebase { flex-wrap: wrap; border-color: rgba(186,130,255,0.3); color: #c9a8ff; background: rgba(186,130,255,0.075); }
.notice.rebase span { min-width: 0; flex: 1 1 auto; }
.notice.conflict button { .notice.conflict button {
margin-left: auto; margin-left: auto;
min-height: 26px; min-height: 26px;
@@ -684,6 +879,20 @@
border-color: rgba(224,160,64,0.45); border-color: rgba(224,160,64,0.45);
color: #f0c070; color: #f0c070;
} }
.notice.rebase button {
min-height: 26px;
padding: 0 10px;
border-color: rgba(186,130,255,0.28);
color: #d5bdff;
background: rgba(186,130,255,0.1);
font-size: 12px;
font-weight: 700;
}
.notice.rebase button:hover:not(:disabled) {
background: rgba(186,130,255,0.18);
border-color: rgba(186,130,255,0.45);
color: #eadfff;
}
/* --- Update toast --- */ /* --- Update toast --- */
@@ -862,7 +1071,7 @@
.workspace { .workspace {
display: grid; display: grid;
grid-template-columns: clamp(220px, 18vw, 280px) minmax(0, 1fr) clamp(400px, 40vw, 620px); grid-template-columns: clamp(220px, 18vw, 280px) minmax(0, 1fr) minmax(560px, var(--history-aside-width, 620px));
flex: 1 1 0; flex: 1 1 0;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
@@ -870,21 +1079,57 @@
} }
.history-aside { .history-aside {
position: relative;
display: grid; display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); grid-template-columns: minmax(0, 1fr) 300px;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
gap: 8px; gap: 8px;
} }
.history-resize-handle {
position: absolute;
z-index: 20;
top: 0;
bottom: 0;
left: -7px;
width: 12px;
cursor: col-resize;
touch-action: none;
}
.history-resize-handle::before {
content: "";
position: absolute;
top: 12px;
bottom: 12px;
left: 5px;
width: 2px;
border-radius: 999px;
background: transparent;
transition: background 120ms ease, box-shadow 120ms ease;
}
.history-resize-handle:hover::before,
.history-resize-handle.resizing::before {
background: rgba(65,209,255,0.62);
box-shadow: 0 0 14px rgba(65,209,255,0.3);
}
.history-resize-handle:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.left-sidebar { .left-sidebar {
display: grid; display: grid;
grid-template-rows: minmax(200px, 0.9fr) minmax(240px, 1.1fr); grid-template-rows: minmax(170px, 0.75fr) minmax(150px, 0.55fr) minmax(220px, 1fr);
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
gap: 8px; gap: 8px;
} }
.left-sidebar:has(.stash-panel.collapsed) {
grid-template-rows: minmax(170px, 0.85fr) auto minmax(220px, 1.15fr);
}
/* --- Main panel --- */ /* --- Main panel --- */
.main-panel { .main-panel {
@@ -1044,6 +1289,158 @@
background: var(--color-surface-dim); background: var(--color-surface-dim);
} }
/* --- Stash panel --- */
.stash-panel {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
}
.stash-panel.collapsed {
grid-template-rows: auto;
min-height: 0;
}
.stash-head-actions {
display: inline-flex;
align-items: center;
gap: 6px;
}
.stash-toggle {
display: inline-grid;
place-items: center;
width: 26px;
min-width: 26px;
min-height: 26px;
padding: 0;
border-color: rgba(94,110,156,0.18);
border-radius: 7px;
color: var(--color-ink-dim);
background: rgba(255,255,255,0.035);
}
.stash-toggle:hover:not(:disabled) {
border-color: rgba(65,209,255,0.28);
color: var(--color-ink);
background: rgba(65,209,255,0.08);
}
.stash-create {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 6px;
padding: 7px;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-surface-dim);
}
.stash-input {
min-width: 0;
height: 28px;
padding: 0 8px;
border: 1px solid var(--color-border-subtle);
border-radius: 7px;
color: var(--color-ink);
background: rgba(255,255,255,0.035);
font-size: 12px;
}
.stash-input:focus {
border-color: rgba(65,209,255,0.42);
outline: none;
box-shadow: 0 0 0 2px rgba(65,209,255,0.1);
}
.stash-check {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--color-ink-dim);
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.stash-check input {
width: 13px;
height: 13px;
accent-color: var(--color-accent);
}
.stash-save-button {
border-color: rgba(65,209,255,0.18);
color: var(--color-ink);
background: rgba(65,209,255,0.075);
}
.stash-list {
display: grid;
align-content: start;
gap: 6px;
min-height: 0;
padding: 7px;
overflow: auto;
}
.stash-empty {
min-height: 48px;
}
.stash-row {
display: grid;
gap: 7px;
padding: 8px;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
background: var(--color-surface-raised);
}
.stash-row-main {
display: grid;
gap: 2px;
min-width: 0;
}
.stash-row-main strong,
.stash-row-main span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stash-row-main strong {
color: var(--color-ink);
font-size: 12px;
font-weight: 750;
}
.stash-row-main span {
color: var(--color-ink-faint);
font-family: var(--font-mono);
font-size: 10.5px;
}
.stash-actions {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.btn-sm.danger {
border-color: rgba(232,96,96,0.2);
color: #ef9b9b;
background: rgba(232,96,96,0.08);
}
.btn-sm.danger:hover:not(:disabled) {
border-color: rgba(232,96,96,0.38);
color: #ffd2d2;
background: rgba(232,96,96,0.14);
}
/* --- Branch list --- */ /* --- Branch list --- */
.branch-head-actions { .branch-head-actions {
@@ -1238,7 +1635,8 @@
.branch-info strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: var(--color-ink); } .branch-info strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: var(--color-ink); }
.branch-info span { display: block; margin-top: 2px; color: var(--color-ink-dim); font-size: 11px; } .branch-info span { display: block; margin-top: 2px; color: var(--color-ink-dim); font-size: 11px; }
.branch-actions { display: flex; align-items: center; justify-content: flex-end; gap: 5px; } .branch-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 5px; }
.branch-actions .btn-sm { min-height: 24px; padding: 0 6px; font-size: 11px; }
.branch-context-menu, .branch-context-menu,
.explorer-context-menu { .explorer-context-menu {
@@ -1281,6 +1679,12 @@
color: var(--color-ink); color: var(--color-ink);
} }
.branch-context-menu .menu-separator {
height: 1px;
margin: 4px 3px;
background: var(--color-border-subtle);
}
.branch-context-menu button.danger { .branch-context-menu button.danger {
color: #ff9aa8; color: #ff9aa8;
} }
@@ -1427,19 +1831,190 @@
.commit-line strong { display: block; overflow: hidden; color: var(--color-ink); font-size: 13px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } .commit-line strong { display: block; overflow: hidden; color: var(--color-ink); font-size: 13px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; }
.commit-line span { display: block; overflow: hidden; margin-top: 3px; color: var(--color-ink-dim); font-family: var(--font-mono); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } .commit-line span { display: block; overflow: hidden; margin-top: 3px; color: var(--color-ink-dim); font-family: var(--font-mono); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.ref-list { display: flex; flex-wrap: wrap; gap: 4px; } .commit-card-head {
.ref-list span { max-width: 100%; overflow: hidden; padding: 2px 7px; border-radius: 999px; color: var(--color-accent); background: rgba(106,154,255,0.13); border: 1px solid rgba(106,154,255,0.22); font-size: 10.5px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 7px;
min-width: 0;
}
.commit-avatar {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border: 1px solid rgba(122,172,255,0.18);
border-radius: 999px;
color: #b8c5df;
background: rgba(18, 22, 38, 0.72);
font-size: 9px;
font-weight: 800;
}
.commit-card-main { display: grid; gap: 2px; min-width: 0; }
.commit-title-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
min-width: 0;
}
.commit-summary {
display: block;
overflow: hidden;
color: #edf2ff;
font-size: 12.5px;
font-weight: 700;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.commit-kind {
display: inline-flex;
align-items: center;
gap: 3px;
min-width: 0;
padding: 1px 5px;
border: 1px solid rgba(94,110,156,0.18);
border-radius: 999px;
color: var(--color-ink-dim);
background: rgba(255,255,255,0.025);
font-size: 9px;
font-weight: 800;
line-height: 1.3;
text-transform: uppercase;
}
.commit-kind.merge { color: #dca6da; border-color: rgba(208,96,192,0.24); background: rgba(208,96,192,0.07); }
.commit-kind.root { color: #d7b66b; border-color: rgba(224,180,92,0.22); background: rgba(224,180,92,0.07); }
.commit-meta-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
color: var(--color-ink-faint);
font-size: 10.5px;
}
.commit-meta-line > * { min-width: 0; }
.commit-hash {
flex: 0 0 auto;
padding: 0;
border: 0;
color: #8db8ff;
background: transparent;
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: 700;
}
.commit-local-branches {
display: inline-flex;
flex: 0 1 auto;
flex-wrap: wrap;
gap: 3px;
min-width: 0;
max-width: min(100%, 260px);
}
.commit-branch-chip {
display: inline-flex;
align-items: center;
gap: 3px;
min-width: 0;
max-width: 100%;
height: 17px;
padding: 0 6px 0 5px;
overflow: hidden;
border: 1px solid rgba(91,209,138,0.22);
border-radius: 999px;
color: #a8eeba;
background: rgba(34,68,48,0.42);
font-family: var(--font-mono);
font-size: 9.5px;
font-weight: 750;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.commit-branch-chip svg {
flex: 0 0 auto;
color: #76d995;
}
.commit-author {
flex: 1 1 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.commit-files { display: grid; gap: 5px; } .ref-list { display: flex; flex-wrap: wrap; gap: 3px; }
.commit-files-toggle { justify-content: flex-start; gap: 5px; min-height: 24px; padding: 0 7px; border-color: transparent; background: transparent; color: var(--color-ink-dim); font-size: 11.5px; font-weight: 700; } .ref-list .ref-chip {
max-width: 100%;
overflow: hidden;
padding: 1px 6px;
border-radius: 999px;
color: var(--color-accent);
background: rgba(106,154,255,0.09);
border: 1px solid rgba(106,154,255,0.16);
font-size: 9.5px;
font-weight: 700;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.ref-list .ref-chip.head {
color: #061021;
border-color: rgba(65,209,255,0.48);
background: linear-gradient(135deg, #41d1ff, #7c6cff);
box-shadow: 0 0 14px rgba(65,209,255,0.2);
}
.ref-list .ref-chip.branch { color: #7ddf9c; background: rgba(78,202,118,0.08); border-color: rgba(78,202,118,0.18); }
.ref-list .ref-chip.remote { color: #aeb6ff; background: rgba(124,108,255,0.08); border-color: rgba(124,108,255,0.18); }
.ref-list .ref-chip.tag { color: #dbc078; background: rgba(224,180,92,0.08); border-color: rgba(224,180,92,0.2); }
.commit-files {
display: grid;
gap: 5px;
padding: 6px;
border: 1px solid rgba(94,110,156,0.14);
border-radius: 8px;
background: rgba(7,8,16,0.16);
}
.commit-files-toggle {
justify-content: flex-start;
gap: 6px;
min-height: 26px;
padding: 0 8px;
border-color: transparent;
background: transparent;
color: var(--color-ink-dim);
font-size: 11.5px;
font-weight: 800;
}
.commit-files-toggle:hover:not(:disabled) { border-color: var(--color-border-subtle); background: var(--color-surface-hover); color: var(--color-ink); } .commit-files-toggle:hover:not(:disabled) { border-color: var(--color-border-subtle); background: var(--color-surface-hover); color: var(--color-ink); }
.commit-file-list { display: grid; gap: 4px; } .commit-file-list { display: grid; gap: 4px; }
.commit-file-button { display: grid; grid-template-columns: auto minmax(0, 1fr); justify-content: stretch; width: 100%; min-height: 28px; padding: 4px 7px; text-align: left; border-color: var(--color-border-subtle); background: rgba(255,255,255,0.025); } .commit-file-button {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
justify-content: stretch;
width: 100%;
min-height: 30px;
padding: 4px 7px;
text-align: left;
border-color: rgba(94,110,156,0.16);
background: rgba(255,255,255,0.03);
}
.commit-file-button:hover:not(:disabled) { border-color: rgba(65,209,255,0.22); background: rgba(65,209,255,0.055); }
.commit-file-button strong { overflow: hidden; color: var(--color-ink-muted); font-family: var(--font-mono); font-size: 11.5px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } .commit-file-button strong { overflow: hidden; color: var(--color-ink-muted); font-family: var(--font-mono); font-size: 11.5px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.commit-actions { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; } .commit-actions { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; }
.commit-actions time { min-width: 0; overflow: hidden; color: var(--color-ink-faint); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } .commit-actions time,
.commit-time {
min-width: 0;
overflow: hidden;
color: var(--color-ink-faint);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.commit-action-buttons { display: flex; flex: 0 0 auto; align-items: center; gap: 5px; } .commit-action-buttons { display: flex; flex: 0 0 auto; align-items: center; gap: 5px; }
.commit-action-buttons button { flex: 0 0 auto; white-space: nowrap; } .commit-action-buttons button { flex: 0 0 auto; white-space: nowrap; }
.file-history-head { align-items: flex-start; } .file-history-head { align-items: flex-start; }
@@ -1551,29 +2126,131 @@
/* --- Git graph --- */ /* --- Git graph --- */
.graph-list { padding: 0; } .graph-list {
padding: 0;
background: var(--color-surface-dim);
}
.graph-row { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0; margin: 0; padding: 0; border: none; border-radius: 0; background: none; } .graph-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0;
min-height: 58px;
margin: 0;
padding: 0;
border: none;
border-radius: 0;
background: none;
}
.graph-row + .graph-row { margin-top: 0; } .graph-row + .graph-row { margin-top: 0; }
.graph-gutter { position: relative; align-self: stretch; background: var(--color-surface); } .graph-gutter {
position: relative;
align-self: stretch;
min-width: 42px;
border-right: 1px solid rgba(94,110,156,0.12);
background: rgba(7,8,16,0.2);
}
.graph-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; } .graph-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.graph-svg path {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.76;
transition: opacity 120ms ease, stroke-width 120ms ease;
}
.graph-svg path.hidden-branch {
opacity: 0.08;
}
.graph-dot { .graph-dot {
position: absolute; position: absolute;
z-index: 2;
top: 50%; top: 50%;
width: 10px; width: 12px;
height: 10px; height: 12px;
border-radius: 999px; border-radius: 999px;
background: var(--dot-color, #5a8cf8); background: var(--dot-color, #5a8cf8);
border: 2px solid var(--color-surface); border: 2px solid #111321;
box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8); box-shadow: 0 0 0 1px rgba(255,255,255,0.07);
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
transition: transform 120ms ease, box-shadow 120ms ease;
}
.graph-dot.hidden-branch {
opacity: 0.16;
box-shadow: none;
}
.graph-dot.tip { width: 14px; height: 14px; box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8); }
.graph-dot.merge {
width: 15px;
height: 15px;
background: var(--color-surface-alt);
border-color: var(--dot-color, #5a8cf8);
box-shadow: 0 0 0 1px rgba(255,255,255,0.08);
}
.graph-hover-branches {
position: absolute;
z-index: 5;
top: 50%;
display: flex;
flex-wrap: wrap;
gap: 3px;
max-width: 190px;
opacity: 0;
pointer-events: none;
transform: translateY(-50%) translateX(-4px);
transition: opacity 120ms ease, transform 120ms ease;
}
.graph-gutter:hover .graph-hover-branches,
.graph-row:hover .graph-hover-branches {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
.graph-hover-branches span {
display: inline-flex;
align-items: center;
gap: 3px;
max-width: 180px;
height: 18px;
padding: 0 6px 0 5px;
overflow: hidden;
border: 1px solid rgba(91,209,138,0.28);
border-radius: 999px;
color: #b2f0c2;
background: rgba(20,35,29,0.94);
box-shadow: 0 8px 22px rgba(0,0,0,0.3);
font-family: var(--font-mono);
font-size: 9.5px;
font-weight: 750;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.graph-hover-branches svg {
flex: 0 0 auto;
color: #76d995;
}
.commit-body {
display: grid;
gap: 5px;
min-width: 0;
padding: 8px 10px;
background: rgba(28,29,48,0.34);
transition: background 120ms ease;
}
.graph-row + .graph-row .commit-body { border-top: 1px solid rgba(94,110,156,0.1); }
.graph-row:hover .commit-body { background: rgba(37,40,62,0.54); }
.graph-row:hover .graph-svg path { opacity: 1; stroke-width: 2.65; }
.graph-row:hover .graph-svg path.hidden-branch { opacity: 0.12; stroke-width: 2.2; }
.graph-row:hover .graph-dot { transform: translate(-50%, -50%) scale(1.12); }
.graph-row:hover .graph-dot.hidden-branch { transform: translate(-50%, -50%) scale(1); }
.graph-row.merge-row .commit-body {
background: rgba(36,31,54,0.46);
}
.graph-row.tip-row .commit-body {
background:
linear-gradient(90deg, rgba(105,167,255,0.055), transparent 32%),
rgba(28,29,48,0.38);
} }
.graph-dot.merge { width: 12px; height: 12px; background: var(--color-surface); border-color: var(--dot-color, #5a8cf8); }
.commit-body { display: grid; gap: 7px; min-width: 0; padding: 10px 12px; }
.graph-row + .graph-row .commit-body { border-top: 1px solid var(--color-border-subtle); }
.graph-row:hover .commit-body { background: var(--color-surface-hover); }
/* --- Compare panel --- */ /* --- Compare panel --- */
@@ -1686,6 +2363,54 @@
max-height: calc(100vh - 32px); max-height: calc(100vh - 32px);
overflow: auto; overflow: auto;
} }
.clone-repository-dialog {
display: block;
width: min(620px, calc(100vw - 32px));
height: auto;
max-height: calc(100vh - 32px);
overflow: auto;
}
.clone-dialog-form {
display: grid;
gap: 14px;
padding: 16px;
}
.clone-dialog-field {
display: grid;
gap: 5px;
min-width: 0;
}
.clone-dialog-field > span {
overflow: hidden;
color: var(--color-ink-faint);
font-size: 10.5px;
font-weight: 800;
text-transform: uppercase;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.04em;
}
.clone-dialog-path-field {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
min-width: 0;
}
.clone-dialog-error {
padding: 9px 10px;
border: 1px solid rgba(232,96,90,0.3);
border-radius: 7px;
color: #f09090;
background: rgba(232,96,90,0.08);
font-size: 12.5px;
line-height: 1.45;
}
.clone-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 2px;
}
.ai-settings-form { .ai-settings-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1721,6 +2446,44 @@
color: #f5f7ff; color: #f5f7ff;
background: linear-gradient(180deg, rgba(100,108,255,0.22), rgba(65,209,255,0.1)); background: linear-gradient(180deg, rgba(100,108,255,0.22), rgba(65,209,255,0.1));
} }
.ai-provider-option-local {
flex-wrap: wrap;
row-gap: 2px;
}
.ai-provider-badge {
flex-basis: 100%;
text-align: center;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-ink-faint);
}
.ai-local-profile-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.ai-local-profile-option {
min-width: 0;
min-height: 34px;
padding: 0 8px;
border-color: var(--color-border-subtle);
background: rgba(255,255,255,0.03);
color: var(--color-ink-dim);
font-size: 12px;
font-weight: 700;
}
.ai-local-profile-option:hover:not(:disabled) {
border-color: var(--color-border);
color: var(--color-ink);
background: var(--color-surface-hover);
}
.ai-local-profile-option.active {
border-color: rgba(65,209,255,0.48);
color: #f5f7ff;
background: linear-gradient(180deg, rgba(65,209,255,0.16), rgba(100,108,255,0.12));
}
.new-branch-form { .new-branch-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1936,6 +2699,12 @@
gap: 14px; gap: 14px;
padding: 18px 16px 16px; padding: 18px 16px 16px;
} }
.branch-delete-body {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 14px;
padding: 18px 16px 16px;
}
.discard-warning-icon { .discard-warning-icon {
display: grid; display: grid;
place-items: center; place-items: center;
@@ -1970,6 +2739,11 @@
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
.branch-delete-body .discard-target {
display: flex;
align-items: center;
gap: 6px;
}
.discard-warning-text { .discard-warning-text {
color: #ffb8bf; color: #ffb8bf;
font-weight: 650; font-weight: 650;
@@ -3009,16 +3783,16 @@
/* --- Responsive breakpoints --- */ /* --- Responsive breakpoints --- */
@media (min-width: 1800px) { @media (min-width: 1800px) {
.workspace { grid-template-columns: 320px minmax(0, 1fr) 680px; } .workspace { grid-template-columns: 320px minmax(0, 1fr) minmax(560px, var(--history-aside-width, 680px)); }
} }
@media (max-width: 1400px) { @media (max-width: 1400px) {
.workspace { grid-template-columns: clamp(200px, 17vw, 265px) minmax(0, 1fr) clamp(400px, 40vw, 580px); } .workspace { grid-template-columns: clamp(200px, 17vw, 265px) minmax(0, 1fr) minmax(540px, var(--history-aside-width, 580px)); }
} }
/* Stack CommitPanel below StatusPanel; history panels stay side by side */ /* Stack CommitPanel below StatusPanel; history panels stay side by side */
@media (max-width: 1100px) { @media (max-width: 1100px) {
.workspace { grid-template-columns: clamp(185px, 16vw, 220px) minmax(0, 1fr) clamp(380px, 38vw, 500px); } .workspace { grid-template-columns: clamp(185px, 16vw, 220px) minmax(0, 1fr) minmax(500px, var(--history-aside-width, 560px)); }
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); } .top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); }
} }
@@ -3026,8 +3800,10 @@
@media (max-width: 960px) { @media (max-width: 960px) {
.workspace { grid-template-columns: 180px minmax(0, 1fr) 300px; gap: 6px; } .workspace { grid-template-columns: 180px minmax(0, 1fr) 300px; gap: 6px; }
.history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1.3fr) minmax(0, 0.7fr); } .history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1.3fr) minmax(0, 0.7fr); }
.history-resize-handle { display: none; }
.shell-body { gap: 6px; } .shell-body { gap: 6px; }
.left-sidebar { gap: 6px; grid-template-rows: minmax(180px, 0.8fr) minmax(200px, 1.2fr); } .left-sidebar { gap: 6px; grid-template-rows: minmax(150px, 0.7fr) minmax(145px, 0.55fr) minmax(190px, 1fr); }
.left-sidebar:has(.stash-panel.collapsed) { grid-template-rows: minmax(150px, 0.8fr) auto minmax(190px, 1.1fr); }
.section-head { min-height: 40px; padding: 6px 10px; } .section-head { min-height: 40px; padding: 6px 10px; }
.repo-summary { height: 40px; padding: 0 10px; } .repo-summary { height: 40px; padding: 0 10px; }
.repo-branch { max-width: 160px; } .repo-branch { max-width: 160px; }
@@ -3044,12 +3820,19 @@
.shell-body { min-height: 100%; gap: 6px; } .shell-body { min-height: 100%; gap: 6px; }
.workspace { grid-template-columns: 1fr; gap: 6px; } .workspace { grid-template-columns: 1fr; gap: 6px; }
.history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(200px, 1fr) minmax(150px, 0.5fr); min-height: 380px; } .history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(200px, 1fr) minmax(150px, 0.5fr); min-height: 380px; }
.left-sidebar { grid-template-rows: minmax(200px, 1fr) minmax(240px, 1.2fr); min-height: 440px; } .left-sidebar { grid-template-rows: minmax(180px, 0.9fr) minmax(150px, 0.55fr) minmax(220px, 1fr); min-height: 560px; }
.left-sidebar:has(.stash-panel.collapsed) { grid-template-rows: minmax(180px, 1fr) auto minmax(220px, 1.1fr); }
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); } .top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); }
.repo-form { grid-template-columns: 1fr; } .repo-form { grid-template-columns: 1fr; }
.repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; } .repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; }
.repo-tab.management { min-width: 0; } .repo-tab.management { min-width: 0; }
.repo-tabs-scroll { grid-column: 1 / -1; order: 2; border-top: 1px solid var(--color-border-subtle); } .repo-tabs-scroll { grid-column: 1 / -1; order: 2; border-top: 1px solid var(--color-border-subtle); }
.repo-management-head,
.repo-management-tools { align-items: stretch; flex-direction: column; }
.repo-management-actions { justify-content: flex-start; }
.clone-dialog-path-field { grid-template-columns: minmax(0, 1fr); }
.clone-dialog-actions { flex-direction: column-reverse; }
.clone-dialog-actions button { width: 100%; }
.repo-row-main { grid-template-columns: minmax(0, 1fr); gap: 2px; align-content: center; padding-left: 12px; } .repo-row-main { grid-template-columns: minmax(0, 1fr); gap: 2px; align-content: center; padding-left: 12px; }
.repo-row { min-height: 58px; } .repo-row { min-height: 58px; }
.repo-row-icon { min-height: 58px; } .repo-row-icon { min-height: 58px; }
+17 -1
View File
@@ -2,7 +2,7 @@
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
import { getVersion } from "@tauri-apps/api/app"; import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte"; import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
export let branch: string = ""; export let branch: string = "";
export let ahead: number = 0; export let ahead: number = 0;
@@ -13,6 +13,7 @@
export let operation: string = ""; export let operation: string = "";
export let autoRefreshEnabled: boolean = true; export let autoRefreshEnabled: boolean = true;
export let autoRefreshInFlight: boolean = false; export let autoRefreshInFlight: boolean = false;
export let onFetch: () => void = () => {};
export let onPull: () => void = () => {}; export let onPull: () => void = () => {};
export let onPush: () => void = () => {}; export let onPush: () => void = () => {};
export let onRefresh: () => void = () => {}; export let onRefresh: () => void = () => {};
@@ -122,6 +123,21 @@
<span class="tb-action-label">Compare</span> <span class="tb-action-label">Compare</span>
</button> </button>
<button
class="tb-action"
onclick={onFetch}
disabled={!hasRepository || isBusy}
title="Fetch"
aria-label="Fetch"
>
{#if operation === "Fetching"}
<LoaderCircle class="spin" size={14} aria-hidden="true" />
{:else}
<CloudDownload size={14} aria-hidden="true" />
{/if}
<span class="tb-action-label">Fetch</span>
</button>
<button <button
class="tb-action" class="tb-action"
onclick={onPull} onclick={onPull}
+62 -5
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Globe, Key, LoaderCircle, X } from "@lucide/svelte"; import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
import { credDelete, credLoad, credSave } from "../git"; import { credDelete, credLoad, credSave } from "../git";
import type { AiSettings, CommitAiProvider, LocalModelOption } from "../types"; import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
interface Props { interface Props {
settings: AiSettings; settings: AiSettings;
@@ -23,6 +23,7 @@
let provider = $state<CommitAiProvider>("local"); let provider = $state<CommitAiProvider>("local");
let localModelId = $state(""); let localModelId = $state("");
let localProfile = $state<CommitAiLocalProfile>("fast");
let openaiModel = $state(""); let openaiModel = $state("");
let anthropicModel = $state(""); let anthropicModel = $state("");
let customBaseUrl = $state(""); let customBaseUrl = $state("");
@@ -35,16 +36,28 @@
let loadingKeys = $state(true); let loadingKeys = $state(true);
let saving = $state(false); let saving = $state(false);
let error = $state(""); let error = $state("");
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
$effect(() => { $effect(() => {
provider = settings.provider; provider = settings.provider;
localModelId = settings.localModelId; localModelId = settings.localModelId;
localProfile = settings.localProfile ?? "fast";
openaiModel = settings.openaiModel; openaiModel = settings.openaiModel;
anthropicModel = settings.anthropicModel; anthropicModel = settings.anthropicModel;
customBaseUrl = settings.customBaseUrl; customBaseUrl = settings.customBaseUrl;
customModel = settings.customModel; customModel = settings.customModel;
}); });
$effect(() => {
if (errorHideTimer) clearTimeout(errorHideTimer);
const currentError = error;
if (currentError) {
errorHideTimer = setTimeout(() => {
if (error === currentError) error = "";
}, 6000);
}
});
onMount(() => { onMount(() => {
(async () => { (async () => {
try { try {
@@ -64,6 +77,10 @@
})(); })();
}); });
onDestroy(() => {
if (errorHideTimer) clearTimeout(errorHideTimer);
});
async function persistKey(target: CloudProvider, value: string) { async function persistKey(target: CloudProvider, value: string) {
const key = CRED_KEYS[target]; const key = CRED_KEYS[target];
const trimmed = value.trim(); const trimmed = value.trim();
@@ -86,6 +103,7 @@
onSave({ onSave({
provider, provider,
localModelId, localModelId,
localProfile,
openaiModel: openaiModel.trim() || "gpt-4o-mini", openaiModel: openaiModel.trim() || "gpt-4o-mini",
anthropicModel: anthropicModel.trim() || "claude-3-5-haiku-latest", anthropicModel: anthropicModel.trim() || "claude-3-5-haiku-latest",
customBaseUrl: customBaseUrl.trim(), customBaseUrl: customBaseUrl.trim(),
@@ -102,13 +120,27 @@
return mb >= 1000 ? `${(mb / 1000).toFixed(1)} GB` : `${mb} MB`; return mb >= 1000 ? `${(mb / 1000).toFixed(1)} GB` : `${mb} MB`;
} }
function recommendedModelForProfile(profile: CommitAiLocalProfile): string {
if (profile === "balanced") return "qwen2.5-1.5b";
if (profile === "detailed") return "qwen2.5-3b";
return "qwen2.5-0.5b";
}
function selectLocalProfile(profile: CommitAiLocalProfile) {
const previousRecommended = recommendedModelForProfile(localProfile);
localProfile = profile;
const nextRecommended = recommendedModelForProfile(profile);
if (!localModelId || localModelId === previousRecommended) {
localModelId = nextRecommended;
}
}
let selectedLocalModel = $derived(localModels.find((option) => option.id === localModelId)); let selectedLocalModel = $derived(localModels.find((option) => option.id === localModelId));
</script> </script>
<div <div
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
> >
<div class="dialog ai-settings-dialog" role="dialog" aria-modal="true" aria-label="AI settings" tabindex="-1"> <div class="dialog ai-settings-dialog" role="dialog" aria-modal="true" aria-label="AI settings" tabindex="-1">
<header class="dialog-header"> <header class="dialog-header">
@@ -123,9 +155,16 @@
<form class="ai-settings-form" onsubmit={(e) => { e.preventDefault(); void handleSave(); }}> <form class="ai-settings-form" onsubmit={(e) => { e.preventDefault(); void handleSave(); }}>
<div class="ai-provider-options" role="radiogroup" aria-label="AI provider"> <div class="ai-provider-options" role="radiogroup" aria-label="AI provider">
<button type="button" class="ai-provider-option" class:active={provider === "local"} onclick={() => { provider = "local"; }}> <button
type="button"
class="ai-provider-option ai-provider-option-local"
class:active={provider === "local"}
disabled
title="Local AI is still in development and not yet available"
>
<Cpu size={16} aria-hidden="true" /> <Cpu size={16} aria-hidden="true" />
Local AI Local AI
<span class="ai-provider-badge">In development</span>
</button> </button>
<button type="button" class="ai-provider-option" class:active={provider === "openai"} onclick={() => { provider = "openai"; }}> <button type="button" class="ai-provider-option" class:active={provider === "openai"} onclick={() => { provider = "openai"; }}>
<Bot size={16} aria-hidden="true" /> <Bot size={16} aria-hidden="true" />
@@ -142,6 +181,23 @@
</div> </div>
{#if provider === "local"} {#if provider === "local"}
<div class="cred-field">
<span class="cred-field-label">Local speed</span>
<div class="ai-local-profile-options" role="radiogroup" aria-label="Local AI speed">
<button type="button" class="ai-local-profile-option" class:active={localProfile === "fast"} onclick={() => selectLocalProfile("fast")}>
<Zap size={15} aria-hidden="true" />
Fast
</button>
<button type="button" class="ai-local-profile-option" class:active={localProfile === "balanced"} onclick={() => selectLocalProfile("balanced")}>
<Gauge size={15} aria-hidden="true" />
Balanced
</button>
<button type="button" class="ai-local-profile-option" class:active={localProfile === "detailed"} onclick={() => selectLocalProfile("detailed")}>
<Sparkles size={15} aria-hidden="true" />
Detailed
</button>
</div>
</div>
<label class="cred-field"> <label class="cred-field">
<span class="cred-field-label">Model</span> <span class="cred-field-label">Model</span>
<select bind:value={localModelId}> <select bind:value={localModelId}>
@@ -156,6 +212,7 @@
Switching downloads the model{selectedLocalModel ? ` (${formatSize(selectedLocalModel.approx_size_mb)})` : ""} Switching downloads the model{selectedLocalModel ? ` (${formatSize(selectedLocalModel.approx_size_mb)})` : ""}
in the background — depending on your internet connection this can take several minutes. in the background — depending on your internet connection this can take several minutes.
After that it stays cached locally and loads instantly on the next start. After that it stays cached locally and loads instantly on the next start.
The speed setting only changes Local AI; API providers keep their existing prompt.
</span> </span>
</div> </div>
{:else if provider === "openai"} {:else if provider === "openai"}
@@ -0,0 +1,76 @@
<script lang="ts">
import { AlertTriangle, GitBranch, LoaderCircle, Trash2, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo } from "../types";
interface Props {
branch: GitBranchInfo;
force: boolean;
isBusy: boolean;
onConfirm: () => void | Promise<void>;
onClose: () => void;
}
let {
branch,
force = false,
isBusy = false,
onConfirm = () => {},
onClose = () => {},
}: Props = $props();
let title = $derived(force ? "Force delete branch?" : "Delete branch?");
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-label={title}>
<header class="dialog-header">
<div>
<span class="eyebrow">{force ? "Force delete" : "Delete branch"}</span>
<p class="dialog-title">{title}</p>
</div>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="branch-delete-body">
<div class="discard-warning-icon" aria-hidden="true">
<AlertTriangle size={22} />
</div>
<div class="discard-confirm-copy">
<p>
{#if force}
This branch is not fully merged. Force deleting removes the branch pointer even if some commits are only reachable from this branch.
{:else}
Delete this local branch from the repository?
{/if}
</p>
<code class="discard-target" title={branch.name}>
<GitBranch size={13} aria-hidden="true" />
{branch.name}
</code>
<p class="discard-warning-text">
{#if force}
Make sure you no longer need the unique commits on this branch.
{:else}
Git will refuse if the branch is not fully merged.
{/if}
</p>
</div>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
<button class="btn-danger" type="button" onclick={onConfirm} disabled={isBusy}>
{#if isBusy}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
{:else}
<Trash2 size={15} aria-hidden="true" />
{/if}
{force ? "Force delete" : "Delete"}
</button>
</footer>
</div>
</div>
+43 -26
View File
@@ -49,6 +49,7 @@
isBusy: boolean; isBusy: boolean;
onCheckout: (branch: GitBranchInfo) => void; onCheckout: (branch: GitBranchInfo) => void;
onMerge: (branch: GitBranchInfo) => void; onMerge: (branch: GitBranchInfo) => void;
onRebase: (branch: GitBranchInfo) => void;
onCreateBranch: (branchName: string) => void | Promise<void>; onCreateBranch: (branchName: string) => void | Promise<void>;
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>; onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>; onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
@@ -62,6 +63,7 @@
isBusy = false, isBusy = false,
onCheckout = () => {}, onCheckout = () => {},
onMerge = () => {}, onMerge = () => {},
onRebase = () => {},
onCreateBranch = () => {}, onCreateBranch = () => {},
onRenameBranch = () => {}, onRenameBranch = () => {},
onDeleteBranch = () => {}, onDeleteBranch = () => {},
@@ -216,13 +218,13 @@
function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) { function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (isBusy || branch.remote) return; if (isBusy) return;
const rect = panelElement?.getBoundingClientRect(); const rect = panelElement?.getBoundingClientRect();
const rawX = rect ? event.clientX - rect.left : event.offsetX; const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY; const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192); const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 92); const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 190);
contextBranch = branch; contextBranch = branch;
contextMenuX = Math.max(8, Math.min(rawX, maxX)); contextMenuX = Math.max(8, Math.min(rawX, maxX));
@@ -242,11 +244,32 @@
async function deleteContextBranch() { async function deleteContextBranch() {
const branch = contextBranch; const branch = contextBranch;
if (!branch || branch.current || isBusy) return; if (!branch || branch.current || branch.remote || isBusy) return;
closeBranchContextMenu(); closeBranchContextMenu();
await onDeleteBranch(branch); await onDeleteBranch(branch);
} }
async function checkoutContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onCheckout(branch);
}
async function mergeContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onMerge(branch);
}
async function rebaseContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onRebase(branch);
}
function handleWindowKeydown(event: KeyboardEvent) { function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeBranchContextMenu(); if (event.key === "Escape") closeBranchContextMenu();
} }
@@ -361,16 +384,6 @@
</div> </div>
{#if row.branch.current} {#if row.branch.current}
<span class="pill pill-active">Current</span> <span class="pill pill-active">Current</span>
{:else}
<div class="branch-actions">
<button class="btn-sm" type="button" onclick={() => onCheckout(row.branch)} disabled={isBusy}>
Checkout
</button>
<button class="btn-sm" type="button" onclick={() => onMerge(row.branch)} disabled={isBusy} title="Merge into current">
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if} {/if}
</article> </article>
{/if} {/if}
@@ -426,6 +439,7 @@
class:current={row.branch.current} class:current={row.branch.current}
style={`--branch-indent: ${row.depth * 16}px;`} style={`--branch-indent: ${row.depth * 16}px;`}
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)} ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
oncontextmenu={(event) => openBranchContextMenu(event, row.branch)}
title={row.branch.current ? "Current branch" : row.branch.name} title={row.branch.current ? "Current branch" : row.branch.name}
> >
<div class="branch-info"> <div class="branch-info">
@@ -437,16 +451,6 @@
</div> </div>
{#if row.branch.current} {#if row.branch.current}
<span class="pill pill-active">Current</span> <span class="pill pill-active">Current</span>
{:else}
<div class="branch-actions">
<button class="btn-sm" type="button" onclick={() => onCheckout(row.branch)} disabled={isBusy}>
Checkout
</button>
<button class="btn-sm" type="button" onclick={() => onMerge(row.branch)} disabled={isBusy} title="Merge into current">
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if} {/if}
</article> </article>
{/if} {/if}
@@ -465,7 +469,20 @@
tabindex="-1" tabindex="-1"
aria-label={`Actions for ${contextBranch.name}`} aria-label={`Actions for ${contextBranch.name}`}
> >
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}> <button type="button" role="menuitem" onclick={checkoutContextBranch} disabled={isBusy || contextBranch.current}>
<GitBranch size={14} aria-hidden="true" />
Checkout
</button>
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
<GitMerge size={14} aria-hidden="true" />
Merge into current
</button>
<button type="button" role="menuitem" onclick={rebaseContextBranch} disabled={isBusy || contextBranch.current}>
<GitBranch size={14} aria-hidden="true" />
Rebase current onto this
</button>
<div class="menu-separator" role="separator"></div>
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}>
<Pencil size={14} aria-hidden="true" /> <Pencil size={14} aria-hidden="true" />
Rename Rename
</button> </button>
@@ -474,8 +491,8 @@
type="button" type="button"
role="menuitem" role="menuitem"
onclick={deleteContextBranch} onclick={deleteContextBranch}
disabled={isBusy || contextBranch.current} disabled={isBusy || contextBranch.current || contextBranch.remote}
title={contextBranch.current ? "Current branch cannot be deleted" : "Delete local branch"} title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Remote branch cannot be deleted here" : "Delete local branch"}
> >
<Trash2 size={14} aria-hidden="true" /> <Trash2 size={14} aria-hidden="true" />
Delete Delete
@@ -0,0 +1,175 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { Download, FolderOpen, LoaderCircle, X } from "@lucide/svelte";
interface Props {
isBusy: boolean;
error: string;
onClone: (remoteUrl: string, parentPath: string, directoryName: string) => void;
onClose: () => void;
}
let {
isBusy = false,
error = "",
onClone = () => {},
onClose = () => {},
}: Props = $props();
let remoteUrl = $state("");
let parentPath = $state("");
let directoryName = $state("");
let directoryNameEdited = $state(false);
let directoryAutoName = $state("");
let browseError = $state("");
let visibleError = $state("");
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
let directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
let canSubmit = $derived(
!isBusy &&
remoteUrl.trim().length > 0 &&
parentPath.trim().length > 0,
);
$effect(() => {
const nextError = error || browseError;
if (errorHideTimer) clearTimeout(errorHideTimer);
visibleError = nextError;
if (nextError) {
errorHideTimer = setTimeout(() => {
visibleError = "";
}, 6000);
}
});
onDestroy(() => {
if (errorHideTimer) clearTimeout(errorHideTimer);
});
function directoryNameFromRemoteUrl(url: string): string {
const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? "";
const lastSegment = trimmed.split(/[\\/:]/).filter(Boolean).pop() ?? "";
return lastSegment.replace(/\.git$/i, "").trim();
}
function errorToMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
try { return JSON.stringify(error) ?? "Unknown error"; } catch { return "Unknown error"; }
}
async function chooseParentFolder() {
if (isBusy) return;
browseError = "";
try {
const selected = await openDialog({
title: "Select clone destination",
directory: true,
multiple: false,
defaultPath: parentPath.trim() || undefined,
});
if (typeof selected !== "string") return;
parentPath = selected;
} catch (error) {
browseError = errorToMessage(error);
}
}
function handleRemoteInput(event: Event) {
const nextRemoteUrl = (event.currentTarget as HTMLInputElement).value;
if (directoryNameEdited) return;
directoryAutoName = directoryNameFromRemoteUrl(nextRemoteUrl);
directoryName = directoryAutoName;
}
function handleDirectoryInput(event: Event) {
const nextDirectoryName = (event.currentTarget as HTMLInputElement).value;
directoryNameEdited = nextDirectoryName.trim().length > 0 && nextDirectoryName !== directoryAutoName;
}
function submit(event: SubmitEvent) {
event.preventDefault();
if (!canSubmit) return;
onClone(remoteUrl.trim(), parentPath.trim(), directoryName.trim());
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label="Clone repository" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Repository Management</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Clone repository</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
<form class="clone-dialog-form" onsubmit={submit}>
<label class="clone-dialog-field">
<span>Remote URL</span>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={remoteUrl}
oninput={handleRemoteInput}
autocomplete="off"
spellcheck="false"
placeholder="https://github.com/org/project.git"
disabled={isBusy}
autofocus
/>
</label>
<label class="clone-dialog-field">
<span>Destination</span>
<div class="clone-dialog-path-field">
<input
bind:value={parentPath}
autocomplete="off"
spellcheck="false"
placeholder="Choose parent folder"
disabled={isBusy}
/>
<button class="btn-secondary" type="button" onclick={chooseParentFolder} disabled={isBusy}>
<FolderOpen size={14} aria-hidden="true" />
Browse
</button>
</div>
</label>
<label class="clone-dialog-field">
<span>Folder name</span>
<input
bind:value={directoryName}
oninput={handleDirectoryInput}
autocomplete="off"
spellcheck="false"
placeholder={directorySuggestion || "Optional"}
disabled={isBusy}
/>
</label>
{#if visibleError}
<div class="clone-dialog-error" role="alert">{visibleError}</div>
{/if}
<div class="clone-dialog-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
Cancel
</button>
<button class="btn-primary" type="submit" disabled={!canSubmit}>
{#if isBusy}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Download size={16} aria-hidden="true" />
{/if}
Clone
</button>
</div>
</form>
</div>
</div>
-1
View File
@@ -182,7 +182,6 @@
<div <div
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
> >
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1"> <div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
@@ -41,7 +41,6 @@
<div <div
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
> >
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1"> <div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
<header class="dialog-header"> <header class="dialog-header">
+14 -5
View File
@@ -14,7 +14,7 @@
} from "@lucide/svelte"; } from "@lucide/svelte";
interface Props { interface Props {
action: "push" | "pull"; action: "push" | "pull" | "fetch" | "clone";
error: string; error: string;
isBusy: boolean; isBusy: boolean;
onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void; onSubmit: (username: string, password: string, save: boolean, expiresAt: string | null) => void;
@@ -43,11 +43,21 @@
password.trim().length > 0 && password.trim().length > 0 &&
(mode === "token" || username.trim().length > 0), (mode === "token" || username.trim().length > 0),
); );
let actionLabel = $derived(action === "push" ? "Push" : "Pull"); let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : "Pull");
let actionTitle = $derived(action === "push" ? "Authenticate push" : "Authenticate pull"); let actionTitle = $derived(
action === "push"
? "Authenticate push"
: action === "fetch"
? "Authenticate fetch"
: action === "clone"
? "Authenticate clone"
: "Authenticate pull",
);
let actionHint = $derived(action === "push" let actionHint = $derived(action === "push"
? "The remote needs write access. Use a password or a token with the appropriate repository permissions." ? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
: "The remote needs access to the repository. Use your Git credentials or a personal access token."); : action === "clone"
? "The repository needs access before it can be cloned. Use your Git credentials or a personal access token."
: "The remote needs access to the repository. Use your Git credentials or a personal access token.");
function handleSubmit(e: SubmitEvent) { function handleSubmit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
@@ -64,7 +74,6 @@
<div <div
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
> >
<div class="cred-card" role="dialog" aria-modal="true" aria-label="Git credentials" tabindex="-1"> <div class="cred-card" role="dialog" aria-modal="true" aria-label="Git credentials" tabindex="-1">
<div class="cred-hero"> <div class="cred-hero">
@@ -25,13 +25,9 @@
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes"); let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes"); let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
function closeFromBackdrop(event: MouseEvent) {
if (isBusy || event.target !== event.currentTarget) return;
onClose();
}
</script> </script>
<div class="dialog-backdrop" role="presentation" onclick={closeFromBackdrop}> <div class="dialog-backdrop" role="presentation">
<div class="dialog discard-confirm-dialog" role="dialog" aria-modal="true" aria-label={title}> <div class="dialog discard-confirm-dialog" role="dialog" aria-modal="true" aria-label={title}>
<header class="dialog-header"> <header class="dialog-header">
<div> <div>
@@ -129,7 +129,6 @@
<div <div
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
> >
<div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global search" tabindex="-1"> <div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global search" tabindex="-1">
<header class="dialog-header"> <header class="dialog-header">
+400 -34
View File
@@ -1,28 +1,38 @@
<script lang="ts"> <script lang="ts">
import { ChevronDown, ChevronRight, GitBranch, RotateCcw } from "@lucide/svelte"; import { ChevronDown, ChevronRight, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types"; import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
interface GraphSegment { interface GraphSegment {
fromCol: number; fromCol: number;
toCol: number; toCol: number;
color: string; color: string;
branches: string[];
} }
interface GraphRow { interface GraphRow {
dotCol: number; dotCol: number;
dotColor: string; dotColor: string;
branchLabels: string[];
top: GraphSegment[]; top: GraphSegment[];
bottom: GraphSegment[]; bottom: GraphSegment[];
} }
interface VisibleCommitEntry {
commit: GitCommit;
graphCommit: GitCommit;
}
const GRAPH_COLORS = [ const GRAPH_COLORS = [
"#2f6fb0", "#4aa777", "#c9851f", "#a05bd0", "#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
"#cc4b6e", "#1f9ab0", "#7a8a1f", "#b0631f", "#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
]; ];
const GRAPH_LANE = 16; const GRAPH_LANE = 18;
interface Props { interface Props {
commits: GitCommit[]; commits: GitCommit[];
localBranchNames: string[];
activeBranch: string;
repositoryKey: string;
hasRepository: boolean; hasRepository: boolean;
isBusy: boolean; isBusy: boolean;
expandedCommitHashes: Set<string>; expandedCommitHashes: Set<string>;
@@ -34,6 +44,9 @@
let { let {
commits = [], commits = [],
localBranchNames = [],
activeBranch = "",
repositoryKey = "",
hasRepository = false, hasRepository = false,
isBusy = false, isBusy = false,
expandedCommitHashes = new Set(), expandedCommitHashes = new Set(),
@@ -43,6 +56,11 @@
onCreateBranchFromCommit = () => {}, onCreateBranchFromCommit = () => {},
}: Props = $props(); }: Props = $props();
let hiddenGraphBranches = $state<Set<string>>(new Set());
let branchDialogOpen = $state(false);
let userAdjustedBranchFilter = $state(false);
let lastDefaultFilterKey = $state("");
function laneColor(col: number): string { function laneColor(col: number): string {
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length]; return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
} }
@@ -51,13 +69,23 @@
return col * GRAPH_LANE + GRAPH_LANE / 2; return col * GRAPH_LANE + GRAPH_LANE / 2;
} }
function computeGraph(items: GitCommit[]): { rows: GraphRow[]; columns: number } { function graphPath(seg: GraphSegment, fromY: number, toY: number): string {
const x1 = graphColX(seg.fromCol);
const x2 = graphColX(seg.toCol);
if (x1 === x2) return `M ${x1} ${fromY} L ${x2} ${toY}`;
const midY = (fromY + toY) / 2;
return `M ${x1} ${fromY} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${toY}`;
}
function computeGraph(items: GitCommit[], branchMembership = new Map<string, string[]>()): { rows: GraphRow[]; columns: number } {
const rows: GraphRow[] = []; const rows: GraphRow[] = [];
let lanes: (string | null)[] = []; let lanes: (string | null)[] = [];
let laneBranches: string[][] = [];
let maxColumns = 1; let maxColumns = 1;
for (const commit of items) { for (const commit of items) {
const before = lanes.slice(); const before = lanes.slice();
const beforeBranches = laneBranches.map((branches) => branches.slice());
let col = before.indexOf(commit.hash); let col = before.indexOf(commit.hash);
if (col === -1) { if (col === -1) {
@@ -67,18 +95,27 @@
const after = before.slice(); const after = before.slice();
while (after.length <= col) after.push(null); while (after.length <= col) after.push(null);
const afterBranches = beforeBranches.map((branches) => branches.slice());
while (afterBranches.length <= col) afterBranches.push([]);
for (let k = 0; k < after.length; k++) { for (let k = 0; k < after.length; k++) {
if (after[k] === commit.hash) after[k] = null; if (after[k] === commit.hash) {
after[k] = null;
afterBranches[k] = [];
}
} }
const currentBranches = branchMembership.get(commit.hash) ?? localBranchRefs(commit);
const commitBranches = uniqueStrings([...(beforeBranches[col] ?? []), ...currentBranches]);
after[col] = commit.parents.length > 0 ? commit.parents[0] : null; after[col] = commit.parents.length > 0 ? commit.parents[0] : null;
afterBranches[col] = after[col] ? commitBranches.slice() : [];
const fromCommit = new Set<number>([col]); const fromCommit = new Set<number>([col]);
for (let p = 1; p < commit.parents.length; p++) { for (let p = 1; p < commit.parents.length; p++) {
let slot = after.indexOf(null); let slot = after.indexOf(null);
if (slot === -1) { slot = after.length; after.push(null); } if (slot === -1) { slot = after.length; after.push(null); afterBranches.push([]); }
after[slot] = commit.parents[p]; after[slot] = commit.parents[p];
afterBranches[slot] = [];
fromCommit.add(slot); fromCommit.add(slot);
} }
@@ -86,19 +123,28 @@
for (let k = 0; k < before.length; k++) { for (let k = 0; k < before.length; k++) {
const target = before[k]; const target = before[k];
if (target == null) continue; if (target == null) continue;
top.push({ fromCol: k, toCol: target === commit.hash ? col : k, color: laneColor(k) }); top.push({ fromCol: k, toCol: target === commit.hash ? col : k, color: laneColor(k), branches: beforeBranches[k] ?? [] });
} }
const bottom: GraphSegment[] = []; const bottom: GraphSegment[] = [];
for (let k = 0; k < after.length; k++) { for (let k = 0; k < after.length; k++) {
if (after[k] == null) continue; if (after[k] == null) continue;
bottom.push({ fromCol: fromCommit.has(k) ? col : k, toCol: k, color: laneColor(k) }); bottom.push({
fromCol: fromCommit.has(k) ? col : k,
toCol: k,
color: laneColor(k),
branches: fromCommit.has(k) ? commitBranches : afterBranches[k] ?? [],
});
} }
rows.push({ dotCol: col, dotColor: laneColor(col), top, bottom }); rows.push({ dotCol: col, dotColor: laneColor(col), branchLabels: currentBranches, top, bottom });
lanes = after.slice(); lanes = after.slice();
while (lanes.length > 0 && lanes[lanes.length - 1] == null) lanes.pop(); laneBranches = afterBranches.map((branches) => branches.slice());
while (lanes.length > 0 && lanes[lanes.length - 1] == null) {
lanes.pop();
laneBranches.pop();
}
maxColumns = Math.max(maxColumns, before.length, after.length, col + 1); maxColumns = Math.max(maxColumns, before.length, after.length, col + 1);
} }
@@ -113,73 +159,350 @@
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path; return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
} }
function baseName(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
function commitFileName(file: GitCommitFile): string {
return file.old_path
? `${baseName(file.old_path)} -> ${baseName(file.path)}`
: baseName(file.path);
}
function authorInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
function commitKind(commit: GitCommit): "merge" | "root" | "commit" {
if (commit.parents.length > 1) return "merge";
if (commit.parents.length === 0) return "root";
return "commit";
}
function commitKindLabel(commit: GitCommit): string {
const kind = commitKind(commit);
if (kind === "merge") return "merge";
if (kind === "root") return "root";
return "commit";
}
let localBranchNameSet = $derived(new Set(localBranchNames));
function uniqueStrings(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
function branchIsVisible(branch: string): boolean {
return !hiddenGraphBranches.has(branch);
}
function visibleBranchLabels(labels: string[]): string[] {
return labels.filter(branchIsVisible);
}
function segmentIsVisible(segment: GraphSegment): boolean {
return segment.branches.length === 0 || segment.branches.some(branchIsVisible);
}
function branchesAreVisible(branches: string[]): boolean {
if (localBranchNames.length === 0) return true;
return branches.some(branchIsVisible);
}
function rowGraphIsVisible(row: GraphRow | undefined): boolean {
return branchesAreVisible(row?.branchLabels ?? []);
}
function nearestVisibleGraphParents(
hash: string,
visibleHashes: Set<string>,
commitByHash: Map<string, GitCommit>,
seen: Set<string>,
): string[] {
if (visibleHashes.has(hash)) return [hash];
if (seen.has(hash)) return [];
seen.add(hash);
const commit = commitByHash.get(hash);
if (!commit) return [];
return uniqueStrings(
commit.parents.flatMap((parentHash) => (
nearestVisibleGraphParents(parentHash, visibleHashes, commitByHash, new Set(seen))
)),
);
}
function branchMembershipByHash(items: GitCommit[]): Map<string, string[]> {
const commitByHash = new Map(items.map((commit) => [commit.hash, commit]));
const membership = new Map<string, Set<string>>();
for (const commit of items) {
for (const branch of localBranchRefs(commit)) {
const stack = [commit.hash];
const seen = new Set<string>();
while (stack.length > 0) {
const hash = stack.pop();
if (!hash || seen.has(hash)) continue;
seen.add(hash);
let branches = membership.get(hash);
if (!branches) {
branches = new Set<string>();
membership.set(hash, branches);
}
branches.add(branch);
const parentCommit = commitByHash.get(hash);
if (parentCommit) stack.push(...parentCommit.parents);
}
}
}
return new Map(
items.map((commit) => [
commit.hash,
localBranchNames.filter((branch) => membership.get(commit.hash)?.has(branch)),
]),
);
}
function visibleCommitEntriesForGraph(items: GitCommit[], branchMembership: Map<string, string[]>): VisibleCommitEntry[] {
const visibleItems = items.filter((commit) => branchesAreVisible(branchMembership.get(commit.hash) ?? []));
const visibleHashes = new Set(visibleItems.map((commit) => commit.hash));
const commitByHash = new Map(items.map((commit) => [commit.hash, commit]));
return visibleItems.map((commit) => {
const parents = uniqueStrings(
commit.parents.flatMap((parentHash) => (
nearestVisibleGraphParents(parentHash, visibleHashes, commitByHash, new Set())
)),
);
return { commit, graphCommit: { ...commit, parents } };
});
}
function toggleGraphBranch(branch: string) {
const next = new Set(hiddenGraphBranches);
if (next.has(branch)) next.delete(branch); else next.add(branch);
hiddenGraphBranches = next;
userAdjustedBranchFilter = true;
}
function showAllGraphBranches() {
hiddenGraphBranches = new Set();
userAdjustedBranchFilter = true;
}
function hideAllGraphBranches() {
hiddenGraphBranches = new Set(localBranchNames);
userAdjustedBranchFilter = true;
}
function openBranchDialog() {
branchDialogOpen = true;
}
function closeBranchDialog() {
branchDialogOpen = false;
}
function handleBranchDialogKeydown(event: KeyboardEvent) {
if (branchDialogOpen && event.key === "Escape") {
closeBranchDialog();
}
}
function localBranchRefs(commit: GitCommit): string[] {
const seen = new Set<string>();
const labels: string[] = [];
for (const ref of commit.refs) {
const label = refLabel(ref);
if (!localBranchNameSet.has(label) || seen.has(label)) continue;
seen.add(label);
labels.push(label);
}
return labels;
}
function visibleRefs(commit: GitCommit): string[] {
return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref)));
}
function refClass(ref: string): string {
if (ref.startsWith("HEAD")) return "head";
if (ref.startsWith("tag:")) return "tag";
if (localBranchNameSet.has(refLabel(ref))) return "branch";
if (ref.includes("/")) return "remote";
return "branch";
}
function refLabel(ref: string): string {
return ref.replace(/^HEAD ->\s*/, "").replace(/^tag:\s*/, "");
}
function formatCommitDate(value: string): string { function formatCommitDate(value: string): string {
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return value; if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date); return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
} }
let graph = $derived(computeGraph(commits)); $effect(() => {
const available = new Set(localBranchNames);
const nextHidden = new Set([...hiddenGraphBranches].filter((branch) => available.has(branch)));
if (nextHidden.size !== hiddenGraphBranches.size) {
hiddenGraphBranches = nextHidden;
}
});
$effect(() => {
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
? activeBranch
: (localBranchNames[0] ?? "");
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
if (!defaultBranch) {
if (lastDefaultFilterKey !== defaultFilterKey) {
hiddenGraphBranches = new Set();
userAdjustedBranchFilter = false;
lastDefaultFilterKey = defaultFilterKey;
}
return;
}
if (lastDefaultFilterKey !== defaultFilterKey) {
userAdjustedBranchFilter = false;
lastDefaultFilterKey = defaultFilterKey;
}
if (!userAdjustedBranchFilter) {
hiddenGraphBranches = new Set(localBranchNames.filter((branch) => branch !== defaultBranch));
}
});
let branchMembership = $derived(branchMembershipByHash(commits));
let visibleCommitEntries = $derived(visibleCommitEntriesForGraph(commits, branchMembership));
let visibleCommits = $derived(visibleCommitEntries.map((entry) => entry.commit));
let graphCommits = $derived(visibleCommitEntries.map((entry) => entry.graphCommit));
let visibleBranchCount = $derived(localBranchNames.filter(branchIsVisible).length);
let graph = $derived(computeGraph(graphCommits, branchMembership));
let graphRows = $derived(graph.rows); let graphRows = $derived(graph.rows);
let graphWidth = $derived(Math.max(graph.columns, 1) * GRAPH_LANE); let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
</script> </script>
<svelte:window onkeydown={handleBranchDialogKeydown} />
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history"> <section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
<div class="section-head"> <div class="section-head">
<div> <div>
<span class="eyebrow">History</span> <span class="eyebrow">History</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2> <h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
</div> </div>
<span class="pill pill-count">{commits.length}</span> <div class="section-head-actions">
{#if localBranchNames.length > 0}
<button
class="graph-branch-dialog-button"
type="button"
onclick={openBranchDialog}
title="Select branches shown in the graph"
>
<GitBranch size={13} aria-hidden="true" />
Branches
<span>{visibleBranchCount}/{localBranchNames.length}</span>
</button>
{/if}
<span class="pill pill-count" title={visibleCommits.length === commits.length ? "Commits" : `${visibleCommits.length} of ${commits.length} commits shown`}>
{visibleCommits.length}
</span>
</div>
</div> </div>
{#if !hasRepository} {#if !hasRepository}
<div class="blank-state">No repository loaded.</div> <div class="blank-state">No repository loaded.</div>
{:else if commits.length === 0} {:else if commits.length === 0}
<div class="blank-state">No commits returned.</div> <div class="blank-state">No commits returned.</div>
{:else if visibleCommits.length === 0}
<div class="blank-state">No commits match the selected branches.</div>
{:else} {:else}
<div class="history-list graph-list overflow-auto"> <div class="history-list graph-list overflow-auto">
{#each commits as item, rowIndex (item.hash)} {#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
{@const item = entry.commit}
{@const row = graphRows[rowIndex]} {@const row = graphRows[rowIndex]}
<article class="commit-row graph-row"> {@const hoverBranchRefs = visibleBranchLabels(row?.branchLabels ?? [])}
{@const otherRefs = visibleRefs(item)}
<article class="commit-row graph-row" class:merge-row={item.parents.length > 1} class:root-row={item.parents.length === 0} class:tip-row={item.refs.length > 0}>
<div class="graph-gutter" style={`width:${graphWidth}px`} aria-hidden="true"> <div class="graph-gutter" style={`width:${graphWidth}px`} aria-hidden="true">
{#if row} {#if row}
<svg class="graph-svg" viewBox={`0 0 ${graphWidth} 100`} preserveAspectRatio="none"> <svg class="graph-svg" viewBox={`0 0 ${graphWidth} 100`} preserveAspectRatio="none">
{#each row.top as seg} {#each row.top as seg}
<line <path
x1={graphColX(seg.fromCol)} y1="0" class:hidden-branch={!segmentIsVisible(seg)}
x2={graphColX(seg.toCol)} y2="50" d={graphPath(seg, 0, 50)}
stroke={seg.color} stroke-width="2" vector-effect="non-scaling-stroke" stroke={seg.color}
stroke-width="2.2"
vector-effect="non-scaling-stroke"
/> />
{/each} {/each}
{#each row.bottom as seg} {#each row.bottom as seg}
<line <path
x1={graphColX(seg.fromCol)} y1="50" class:hidden-branch={!segmentIsVisible(seg)}
x2={graphColX(seg.toCol)} y2="100" d={graphPath(seg, 50, 100)}
stroke={seg.color} stroke-width="2" vector-effect="non-scaling-stroke" stroke={seg.color}
stroke-width="2.2"
vector-effect="non-scaling-stroke"
/> />
{/each} {/each}
</svg> </svg>
<span <span
class="graph-dot" class="graph-dot"
class:merge={item.parents.length > 1} class:merge={item.parents.length > 1}
class:tip={item.refs.length > 0}
class:hidden-branch={!rowGraphIsVisible(row)}
title={hoverBranchRefs.length > 0 ? `Contained in: ${hoverBranchRefs.join(", ")}` : item.short_hash}
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`} style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
></span> ></span>
{#if hoverBranchRefs.length > 0}
<div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}>
{#each hoverBranchRefs as branch}
<span title={branch}>
<GitBranch size={10} aria-hidden="true" />
{branch}
</span>
{/each}
</div>
{/if}
{/if} {/if}
</div> </div>
<div class="commit-body"> <div class="commit-body">
<div class="commit-line"> <div class="commit-card-head">
<div> <span class="commit-avatar">
<strong title={item.summary}>{item.summary}</strong> {authorInitials(item.author_name)}
<span>{item.short_hash} - {item.author_name}</span> </span>
<div class="commit-card-main">
<div class="commit-title-row">
<strong class="commit-summary" title={item.summary}>{item.summary}</strong>
<span class={`commit-kind ${commitKind(item)}`}>
{#if item.parents.length > 1}
<GitMerge size={12} aria-hidden="true" />
{/if}
{commitKindLabel(item)}
</span>
</div>
<div class="commit-meta-line">
<span class="commit-hash">{item.short_hash}</span>
<span class="commit-author" title={item.author_email}>{item.author_name}</span>
</div>
</div> </div>
</div> </div>
{#if item.refs.length > 0} {#if otherRefs.length > 0}
<div class="ref-list" aria-label="Commit refs"> <div class="ref-list" aria-label="Commit refs">
{#each item.refs as ref} {#each otherRefs as ref}
<span>{ref}</span> <span class={`ref-chip ${refClass(ref)}`}>{refLabel(ref)}</span>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -208,10 +531,10 @@
type="button" type="button"
onclick={() => onPreviewCommitFile(item, file)} onclick={() => onPreviewCommitFile(item, file)}
disabled={isBusy} disabled={isBusy}
title="Show differences before restoring" title={`Show differences before restoring - ${displayCommitFile(file)}`}
> >
<span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span> <span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span>
<strong>{displayCommitFile(file)}</strong> <strong>{commitFileName(file)}</strong>
</button> </button>
{/each} {/each}
</div> </div>
@@ -220,7 +543,7 @@
{/if} {/if}
<div class="commit-actions"> <div class="commit-actions">
<time datetime={item.date}>{formatCommitDate(item.date)}</time> <time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons"> <div class="commit-action-buttons">
<button class="btn-sm" type="button" onclick={() => onCreateBranchFromCommit(item)} disabled={isBusy} title="Create a new branch from this commit"> <button class="btn-sm" type="button" onclick={() => onCreateBranchFromCommit(item)} disabled={isBusy} title="Create a new branch from this commit">
<GitBranch size={15} aria-hidden="true" /> <GitBranch size={15} aria-hidden="true" />
@@ -238,3 +561,46 @@
</div> </div>
{/if} {/if}
</section> </section>
{#if branchDialogOpen}
<div class="branch-filter-backdrop" role="presentation">
<div
class="branch-filter-dialog"
role="dialog"
aria-modal="true"
aria-label="Select visible branches"
>
<header class="branch-filter-dialog-head">
<div>
<span class="eyebrow">Git graph</span>
<h3>Visible branches</h3>
</div>
<button class="dialog-icon-button" type="button" onclick={closeBranchDialog} aria-label="Close branch selection">
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="branch-filter-summary">
<span>{visibleBranchCount} of {localBranchNames.length} branches selected</span>
<div class="branch-filter-actions">
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === localBranchNames.length}>Show all</button>
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
</div>
</div>
<div class="branch-filter-dialog-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>
</div>
</div>
{/if}
@@ -29,7 +29,6 @@
<div <div
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
> >
<div class="dialog new-branch-dialog" role="dialog" aria-modal="true" aria-label="Create branch from commit" tabindex="-1"> <div class="dialog new-branch-dialog" role="dialog" aria-modal="true" aria-label="Create branch from commit" tabindex="-1">
<header class="dialog-header"> <header class="dialog-header">
@@ -33,7 +33,6 @@
<div <div
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
> >
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1"> <div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
<header class="dialog-header"> <header class="dialog-header">
+3 -4
View File
@@ -254,20 +254,19 @@
<div <div
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
> >
<div <div
class="dialog" class="dialog"
style="grid-template-rows: auto minmax(0,1fr) auto;" style="grid-template-rows: auto minmax(0,1fr) auto;"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Resolve merge conflicts" aria-label="Resolve conflicts"
tabindex="-1" tabindex="-1"
> >
<header class="dialog-header"> <header class="dialog-header">
<div> <div>
<span class="eyebrow">Resolve</span> <span class="eyebrow">Resolve</span>
<h2 class="dialog-title">Merge conflicts</h2> <h2 class="dialog-title">Conflicts</h2>
</div> </div>
<button class="dialog-close" type="button" onclick={onClose} title="Close"> <button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" /> <X size={18} aria-hidden="true" />
@@ -275,7 +274,7 @@
</header> </header>
{#if conflictedFiles.length === 0} {#if conflictedFiles.length === 0}
<div class="blank-state">All conflicts resolved. You can commit the merge now.</div> <div class="blank-state">All conflicts resolved. Continue the current operation when ready.</div>
{:else} {:else}
<div class="dialog-body"> <div class="dialog-body">
<aside class="dialog-files" aria-label="Conflicted files"> <aside class="dialog-files" aria-label="Conflicted files">
+134
View File
@@ -0,0 +1,134 @@
<script lang="ts">
import { Archive, ChevronDown, ChevronRight, Download, Trash2, Upload } from "@lucide/svelte";
import type { GitStash } from "../types";
interface Props {
stashes: GitStash[];
changedCount: number;
hasRepository: boolean;
isBusy: boolean;
onPush: (message: string, includeUntracked: boolean) => void;
onApply: (stash: GitStash) => void;
onPop: (stash: GitStash) => void;
onDrop: (stash: GitStash) => void;
}
let {
stashes = [],
changedCount = 0,
hasRepository = false,
isBusy = false,
onPush = () => {},
onApply = () => {},
onPop = () => {},
onDrop = () => {},
}: Props = $props();
let message = $state("");
let includeUntracked = $state(true);
let open = $state(false);
function submitPush() {
onPush(message, includeUntracked);
message = "";
}
function stashTitle(stash: GitStash): string {
return stash.message || stash.selector;
}
</script>
<section class="panel stash-panel overflow-hidden" class:collapsed={!open} aria-label="Git stash">
<div class="section-head">
<div>
<span class="eyebrow">Stash</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Shelved changes</h2>
</div>
<div class="stash-head-actions">
<button
class="stash-toggle"
type="button"
onclick={() => { open = !open; }}
aria-expanded={open}
title={open ? "Collapse stash panel" : "Expand stash panel"}
>
{#if open}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
</button>
<span class="pill pill-count">{stashes.length}</span>
</div>
</div>
{#if !open}
<!-- collapsed -->
{:else if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else}
<div class="stash-create">
<input
class="stash-input"
type="text"
bind:value={message}
placeholder="Optional message"
disabled={isBusy || changedCount === 0}
onkeydown={(event) => {
if (event.key === "Enter" && changedCount > 0 && !isBusy) submitPush();
}}
/>
<label class="stash-check">
<input type="checkbox" bind:checked={includeUntracked} disabled={isBusy || changedCount === 0} />
Untracked
</label>
<button
class="btn-sm stash-save-button"
type="button"
onclick={submitPush}
disabled={isBusy || changedCount === 0}
title="Save current working tree changes to a stash"
>
<Archive size={14} aria-hidden="true" />
Stash
</button>
</div>
{#if stashes.length === 0}
<div class="blank-state stash-empty">No stashes saved.</div>
{:else}
<div class="stash-list">
{#each stashes as stash (stash.selector)}
<article class="stash-row">
<div class="stash-row-main">
<strong title={stashTitle(stash)}>{stashTitle(stash)}</strong>
<span>
{stash.selector}
{#if stash.branch}
on {stash.branch}
{/if}
{#if stash.date}
- {stash.date}
{/if}
</span>
</div>
<div class="stash-actions">
<button class="btn-sm" type="button" onclick={() => onApply(stash)} disabled={isBusy} title="Apply stash and keep it">
<Download size={13} aria-hidden="true" />
Apply
</button>
<button class="btn-sm" type="button" onclick={() => onPop(stash)} disabled={isBusy} title="Apply stash and remove it if successful">
<Upload size={13} aria-hidden="true" />
Pop
</button>
<button class="btn-sm danger" type="button" onclick={() => onDrop(stash)} disabled={isBusy} title="Delete stash">
<Trash2 size={13} aria-hidden="true" />
Drop
</button>
</div>
</article>
{/each}
</div>
{/if}
{/if}
</section>
+74 -2
View File
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { import type {
CommitAiLocalProfile,
CommitAiProvider, CommitAiProvider,
CommitAiStatus, CommitAiStatus,
ConflictFile, ConflictFile,
@@ -9,6 +10,7 @@ import type {
GitCommitComparison, GitCommitComparison,
GitRepositoryFile, GitRepositoryFile,
GitSearchHit, GitSearchHit,
GitStash,
GitStatus, GitStatus,
LocalModelOption, LocalModelOption,
PatchApplyAction, PatchApplyAction,
@@ -32,14 +34,42 @@ export function openRepositoryBundle(path: string, commitLimit = 100): Promise<R
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit }); return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
} }
export function cloneRepository(
remoteUrl: string,
parentPath: string,
directoryName?: string,
username?: string,
password?: string,
commitLimit = 100,
): Promise<RepositoryBundle> {
return invoke<RepositoryBundle>("clone_repository", {
remoteUrl,
parentPath,
directoryName: directoryName?.trim() ? directoryName.trim() : null,
username: username ?? null,
password: password ?? null,
commitLimit,
});
}
export function getStatus(path: string): Promise<GitStatus> { export function getStatus(path: string): Promise<GitStatus> {
return invoke<GitStatus>("get_status", { path }); return invoke<GitStatus>("get_status", { 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> {
return invoke<void>("set_sync_badge", { ahead, behind, changes });
}
export function listBranches(path: string): Promise<GitBranch[]> { export function listBranches(path: string): Promise<GitBranch[]> {
return invoke<GitBranch[]>("list_branches", { path }); return invoke<GitBranch[]>("list_branches", { path });
} }
export function listStashes(path: string): Promise<GitStash[]> {
return invoke<GitStash[]>("list_stashes", { path });
}
export function checkoutBranch(path: string, branch: string): Promise<GitStatus> { export function checkoutBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("checkout_branch", { path, branch }); return invoke<GitStatus>("checkout_branch", { path, branch });
} }
@@ -60,8 +90,8 @@ export function renameBranch(
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch }); return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
} }
export function deleteBranch(path: string, branch: string): Promise<GitStatus> { export function deleteBranch(path: string, branch: string, force = false): Promise<GitStatus> {
return invoke<GitStatus>("delete_branch", { path, branch }); return invoke<GitStatus>("delete_branch", { path, branch, force });
} }
export function stageFiles(path: string, files: string[]): Promise<GitStatus> { export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
@@ -97,6 +127,30 @@ export function commit(path: string, message: string): Promise<GitStatus> {
return invoke<GitStatus>("commit", { path, message }); return invoke<GitStatus>("commit", { path, message });
} }
export function stashPush(
path: string,
message?: string,
includeUntracked = true,
): Promise<GitStatus> {
return invoke<GitStatus>("stash_push", {
path,
message: message?.trim() ? message.trim() : null,
includeUntracked,
});
}
export function stashApply(path: string, selector: string): Promise<GitStatus> {
return invoke<GitStatus>("stash_apply", { path, selector });
}
export function stashPop(path: string, selector: string): Promise<GitStatus> {
return invoke<GitStatus>("stash_pop", { path, selector });
}
export function stashDrop(path: string, selector: string): Promise<GitStatus> {
return invoke<GitStatus>("stash_drop", { path, selector });
}
export function commitAiStatus(): Promise<CommitAiStatus> { export function commitAiStatus(): Promise<CommitAiStatus> {
return invoke<CommitAiStatus>("commit_ai_status"); return invoke<CommitAiStatus>("commit_ai_status");
} }
@@ -112,6 +166,7 @@ export function commitAiLocalModels(): Promise<LocalModelOption[]> {
export interface CommitAiGenerateOptions { export interface CommitAiGenerateOptions {
notes?: string; notes?: string;
provider: CommitAiProvider; provider: CommitAiProvider;
localProfile?: CommitAiLocalProfile;
model?: string; model?: string;
apiKey?: string; apiKey?: string;
baseUrl?: string; baseUrl?: string;
@@ -122,6 +177,7 @@ export function commitAiGenerate(path: string, options: CommitAiGenerateOptions)
path, path,
notes: options.notes, notes: options.notes,
provider: options.provider, provider: options.provider,
localProfile: options.localProfile,
model: options.model, model: options.model,
apiKey: options.apiKey, apiKey: options.apiKey,
baseUrl: options.baseUrl, baseUrl: options.baseUrl,
@@ -132,6 +188,10 @@ export function pull(path: string, username?: string, password?: string): Promis
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null }); return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null });
} }
export function fetchRemote(path: string, username?: string, password?: string): Promise<GitStatus> {
return invoke<GitStatus>("fetch", { path, username: username ?? null, password: password ?? null });
}
export function push(path: string, username?: string, password?: string): Promise<GitStatus> { export function push(path: string, username?: string, password?: string): Promise<GitStatus> {
return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null }); return invoke<GitStatus>("push", { path, username: username ?? null, password: password ?? null });
} }
@@ -177,6 +237,18 @@ export function mergeBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("merge_branch", { path, branch }); return invoke<GitStatus>("merge_branch", { path, branch });
} }
export function rebaseBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_branch", { path, branch });
}
export function rebaseContinue(path: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_continue", { path });
}
export function rebaseAbort(path: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_abort", { path });
}
export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]> { export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]> {
return invoke<GitRepositoryFile[]>("list_repository_files", { path }); return invoke<GitRepositoryFile[]>("list_repository_files", { path });
} }
+13
View File
@@ -9,6 +9,7 @@ export type FileStatusKind =
export type CommitAiPhase = "idle" | "loading" | "ready" | "error"; export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom"; export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
export interface CommitAiStatus { export interface CommitAiStatus {
phase: CommitAiPhase; phase: CommitAiPhase;
@@ -25,6 +26,7 @@ export interface LocalModelOption {
export interface AiSettings { export interface AiSettings {
provider: CommitAiProvider; provider: CommitAiProvider;
localModelId: string; localModelId: string;
localProfile: CommitAiLocalProfile;
openaiModel: string; openaiModel: string;
anthropicModel: string; anthropicModel: string;
customBaseUrl: string; customBaseUrl: string;
@@ -39,6 +41,7 @@ export interface GitStatus {
behind: number; behind: number;
files: GitFileStatus[]; files: GitFileStatus[];
clean: boolean; clean: boolean;
rebase_in_progress: boolean;
} }
export interface GitFileStatus { export interface GitFileStatus {
@@ -56,6 +59,15 @@ export interface GitBranch {
remote: boolean; remote: boolean;
} }
export interface GitStash {
selector: string;
index: number;
hash: string;
branch: string | null;
message: string;
date: string;
}
export interface GitCommit { export interface GitCommit {
hash: string; hash: string;
short_hash: string; short_hash: string;
@@ -83,6 +95,7 @@ export interface GitRepositoryFile {
export interface RepositoryBundle { export interface RepositoryBundle {
status: GitStatus; status: GitStatus;
branches: GitBranch[]; branches: GitBranch[];
stashes: GitStash[];
commits: GitCommit[]; commits: GitCommit[];
files: GitRepositoryFile[]; files: GitRepositoryFile[];
} }