Features/ai commits #8
@@ -34,7 +34,44 @@
|
|||||||
"Read(//mnt/c/Users/cbr/Desktop/src-tauri/src/**)",
|
"Read(//mnt/c/Users/cbr/Desktop/src-tauri/src/**)",
|
||||||
"Bash(sudo apt install -y libdbus-1-dev pkg-config)",
|
"Bash(sudo apt install -y libdbus-1-dev pkg-config)",
|
||||||
"Bash(dpkg -l)",
|
"Bash(dpkg -l)",
|
||||||
"Bash(apt list *)"
|
"Bash(apt list *)",
|
||||||
|
"Bash(cargo search *)",
|
||||||
|
"Bash(curl -s \"https://crates.io/api/v1/crates/mistralrs\")",
|
||||||
|
"Bash(cargo info *)",
|
||||||
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
|
"Bash(gh api *)",
|
||||||
|
"WebFetch(domain:github.com)",
|
||||||
|
"WebFetch(domain:ericlbuehler.github.io)",
|
||||||
|
"WebFetch(domain:docs.rs)",
|
||||||
|
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-1.5B-Instruct-GGUF\")",
|
||||||
|
"Bash(python3 -c ' *)",
|
||||||
|
"Bash(curl -sI \"https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf\")",
|
||||||
|
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-1.5B-Instruct-GGUF?blobs=true\")",
|
||||||
|
"Bash(grep -n 'from \"\\\\./lib/git\"\\\\|from \"\\\\./lib/types\"\\\\|^ commit,$' src/App.svelte)",
|
||||||
|
"Bash(kill 24343 24363 24375 24376)",
|
||||||
|
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-0.5B-Instruct-GGUF?blobs=true\")",
|
||||||
|
"Bash(curl -s \"https://huggingface.co/api/models/bartowski/Llama-3.2-3B-Instruct-GGUF?blobs=true\")",
|
||||||
|
"Bash(curl -s \"https://huggingface.co/api/models/bartowski/Llama-3.2-3B-Instruct-GGUF\")",
|
||||||
|
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct-GGUF\")",
|
||||||
|
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct-GGUF?blobs=true\")",
|
||||||
|
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct\")",
|
||||||
|
"Bash(: *)",
|
||||||
|
"Bash(exit 0 *)",
|
||||||
|
"Bash(rustc -O sanitize_test.rs -o sanitize_test)",
|
||||||
|
"Bash(./sanitize_test)",
|
||||||
|
"Bash(echo \"exit:$?\")",
|
||||||
|
"Bash(grep -rlP \"[äöüßÄÖÜ]\" src src-tauri/src src-tauri/crates --include=\"*.rs\" --include=\"*.svelte\" --include=\"*.ts\")",
|
||||||
|
"Bash(echo \"---exit $?---\")",
|
||||||
|
"Bash(apt-cache policy *)",
|
||||||
|
"Bash(timeout 5 curl -sI http://archive.ubuntu.com)",
|
||||||
|
"Bash(sudo -n apt-get install -y libdbus-1-dev pkg-config)",
|
||||||
|
"Bash(grep -n '\"Unerwarteter Git-Log-Eintrag: {}\",' src-tauri/src/git.rs)",
|
||||||
|
"Bash(grep -E \"git_lite$|tauri_git_lite$\")",
|
||||||
|
"Bash(rustfmt --edition 2024 --check src-tauri/src/git.rs)",
|
||||||
|
"Bash(echo \"EXIT:$?\")",
|
||||||
|
"Bash(ls target/)",
|
||||||
|
"Bash(rustup target *)",
|
||||||
|
"Bash(echo \"exit code: $?\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,3 +6,4 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
~
|
~
|
||||||
.codex*
|
.codex*
|
||||||
|
target
|
||||||
|
|||||||
Generated
+3530
-41
File diff suppressed because it is too large
Load Diff
@@ -2,16 +2,23 @@
|
|||||||
name = "git_lite"
|
name = "git_lite"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Rust backend for a lightweight Git desktop client"
|
description = "Rust backend for a lightweight Git desktop client"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
rust-version = "1.77"
|
rust-version = "1.88"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = [".", "crates/commit_ai"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-dialog = "=2.7.0"
|
tauri-plugin-dialog = "=2.7.0"
|
||||||
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
||||||
|
commit_ai = { path = "crates/commit_ai" }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
|
||||||
|
[package]
|
||||||
|
name = "commit_ai"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
mistralrs = "0.8"
|
||||||
|
tokio = { version = "1", features = ["sync"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{build_messages, sanitize_message};
|
||||||
|
|
||||||
|
// Generous sizing so a detailed body with bullet points isn't cut off.
|
||||||
|
const DEFAULT_MAX_TOKENS: u32 = 1500;
|
||||||
|
// Without a timeout, a hanging endpoint would permanently block the "AI" button.
|
||||||
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
fn http_client() -> Result<reqwest::Client, String> {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.timeout(REQUEST_TIMEOUT)
|
||||||
|
.build()
|
||||||
|
.map_err(|err| format!("Could not create HTTP client: {err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct OpenAiMessage {
|
||||||
|
role: &'static str,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct OpenAiRequest {
|
||||||
|
model: String,
|
||||||
|
messages: Vec<OpenAiMessage>,
|
||||||
|
temperature: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct OpenAiResponseMessage {
|
||||||
|
content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct OpenAiChoice {
|
||||||
|
message: OpenAiResponseMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct OpenAiResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
choices: Vec<OpenAiChoice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn openai_compatible_request(
|
||||||
|
url: String,
|
||||||
|
bearer: Option<&str>,
|
||||||
|
model: &str,
|
||||||
|
diff: &str,
|
||||||
|
notes: Option<&str>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let (system, user) = build_messages(diff, notes)?;
|
||||||
|
let body = OpenAiRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
messages: vec![
|
||||||
|
OpenAiMessage { role: "system", content: system },
|
||||||
|
OpenAiMessage { role: "user", content: user },
|
||||||
|
],
|
||||||
|
temperature: 0.3,
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = http_client()?;
|
||||||
|
let mut request = client.post(url).json(&body);
|
||||||
|
if let Some(key) = bearer.filter(|k| !k.trim().is_empty()) {
|
||||||
|
request = request.bearer_auth(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Request to the AI model failed: {err}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("API error ({status}): {text}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: OpenAiResponse = serde_json::from_str(&text)
|
||||||
|
.map_err(|err| format!("Could not process response: {err}"))?;
|
||||||
|
|
||||||
|
parsed
|
||||||
|
.choices
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.and_then(|choice| choice.message.content)
|
||||||
|
.map(|content| sanitize_message(&content))
|
||||||
|
.filter(|content| !content.is_empty())
|
||||||
|
.ok_or_else(|| "The model did not return a response.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn generate_openai(
|
||||||
|
api_key: &str,
|
||||||
|
model: &str,
|
||||||
|
diff: &str,
|
||||||
|
notes: Option<&str>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
if api_key.trim().is_empty() {
|
||||||
|
return Err("OpenAI API key is missing.".to_string());
|
||||||
|
}
|
||||||
|
openai_compatible_request(
|
||||||
|
"https://api.openai.com/v1/chat/completions".to_string(),
|
||||||
|
Some(api_key),
|
||||||
|
model,
|
||||||
|
diff,
|
||||||
|
notes,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn generate_custom(
|
||||||
|
base_url: &str,
|
||||||
|
api_key: Option<&str>,
|
||||||
|
model: &str,
|
||||||
|
diff: &str,
|
||||||
|
notes: Option<&str>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
if base_url.trim().is_empty() {
|
||||||
|
return Err("Endpoint URL is missing.".to_string());
|
||||||
|
}
|
||||||
|
let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
|
||||||
|
openai_compatible_request(url, api_key, model, diff, notes).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct AnthropicMessage {
|
||||||
|
role: &'static str,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct AnthropicRequest {
|
||||||
|
model: String,
|
||||||
|
max_tokens: u32,
|
||||||
|
system: String,
|
||||||
|
messages: Vec<AnthropicMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AnthropicContentBlock {
|
||||||
|
#[serde(default)]
|
||||||
|
text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AnthropicResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
content: Vec<AnthropicContentBlock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn generate_anthropic(
|
||||||
|
api_key: &str,
|
||||||
|
model: &str,
|
||||||
|
diff: &str,
|
||||||
|
notes: Option<&str>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
if api_key.trim().is_empty() {
|
||||||
|
return Err("Anthropic API key is missing.".to_string());
|
||||||
|
}
|
||||||
|
let (system, user) = build_messages(diff, notes)?;
|
||||||
|
let body = AnthropicRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
max_tokens: DEFAULT_MAX_TOKENS,
|
||||||
|
system,
|
||||||
|
messages: vec![AnthropicMessage { role: "user", content: user }],
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = http_client()?;
|
||||||
|
let response = client
|
||||||
|
.post("https://api.anthropic.com/v1/messages")
|
||||||
|
.header("x-api-key", api_key)
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Request to Anthropic failed: {err}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("API error ({status}): {text}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: AnthropicResponse = serde_json::from_str(&text)
|
||||||
|
.map_err(|err| format!("Could not process response: {err}"))?;
|
||||||
|
|
||||||
|
parsed
|
||||||
|
.content
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|block| block.text)
|
||||||
|
.map(|text| sanitize_message(&text))
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.ok_or_else(|| "The model did not return a response.".to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
mod cloud;
|
||||||
|
|
||||||
|
pub use cloud::{generate_anthropic, generate_custom, generate_openai};
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use mistralrs::{GgufModelBuilder, Model, TextMessageRole, TextMessages};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
/// One selectable local (on-device) model. Larger models produce better commit messages
|
||||||
|
/// but take longer to download (first run only, then cached) and run slower on CPU.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct LocalModelOption {
|
||||||
|
pub id: &'static str,
|
||||||
|
pub label: &'static str,
|
||||||
|
pub approx_size_mb: u32,
|
||||||
|
repo: &'static str,
|
||||||
|
file: &'static str,
|
||||||
|
tokenizer_repo: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-1.5b";
|
||||||
|
|
||||||
|
pub const LOCAL_MODELS: &[LocalModelOption] = &[
|
||||||
|
LocalModelOption {
|
||||||
|
id: "qwen2.5-0.5b",
|
||||||
|
label: "Qwen2.5 0.5B Instruct — fast, lower quality",
|
||||||
|
approx_size_mb: 490,
|
||||||
|
repo: "Qwen/Qwen2.5-0.5B-Instruct-GGUF",
|
||||||
|
file: "qwen2.5-0.5b-instruct-q4_k_m.gguf",
|
||||||
|
tokenizer_repo: "Qwen/Qwen2.5-0.5B-Instruct",
|
||||||
|
},
|
||||||
|
LocalModelOption {
|
||||||
|
id: "qwen2.5-1.5b",
|
||||||
|
label: "Qwen2.5 1.5B Instruct — recommended",
|
||||||
|
approx_size_mb: 1050,
|
||||||
|
repo: "Qwen/Qwen2.5-1.5B-Instruct-GGUF",
|
||||||
|
file: "qwen2.5-1.5b-instruct-q4_k_m.gguf",
|
||||||
|
tokenizer_repo: "Qwen/Qwen2.5-1.5B-Instruct",
|
||||||
|
},
|
||||||
|
LocalModelOption {
|
||||||
|
id: "qwen2.5-3b",
|
||||||
|
label: "Qwen2.5 3B Instruct — best quality, slower",
|
||||||
|
approx_size_mb: 2100,
|
||||||
|
repo: "Qwen/Qwen2.5-3B-Instruct-GGUF",
|
||||||
|
file: "qwen2.5-3b-instruct-q4_k_m.gguf",
|
||||||
|
tokenizer_repo: "Qwen/Qwen2.5-3B-Instruct",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
fn find_local_model(model_id: &str) -> Option<&'static LocalModelOption> {
|
||||||
|
LOCAL_MODELS.iter().find(|option| option.id == model_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum CommitAiPhase {
|
||||||
|
/// Nothing has been requested yet.
|
||||||
|
Idle,
|
||||||
|
/// Downloading (first run only, then cached by hf-hub) and/or loading into memory.
|
||||||
|
Loading,
|
||||||
|
Ready,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct CommitAiStatus {
|
||||||
|
pub phase: CommitAiPhase,
|
||||||
|
pub model_id: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Inner {
|
||||||
|
phase: CommitAiPhase,
|
||||||
|
model_id: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
model: Option<Arc<Model>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manages the local (on-device) model only. Cloud providers are stateless HTTP calls
|
||||||
|
/// (see [`cloud`]) and don't need this — there's nothing to download or keep loaded.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct CommitAiEngine {
|
||||||
|
inner: Arc<RwLock<Inner>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CommitAiEngine {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(RwLock::new(Inner {
|
||||||
|
phase: CommitAiPhase::Idle,
|
||||||
|
model_id: None,
|
||||||
|
error: None,
|
||||||
|
model: None,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommitAiEngine {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(&self) -> CommitAiStatus {
|
||||||
|
let guard = self.inner.read().await;
|
||||||
|
CommitAiStatus {
|
||||||
|
phase: guard.phase,
|
||||||
|
model_id: guard.model_id.clone(),
|
||||||
|
error: guard.error.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads (first run only; hf-hub caches the files afterwards) and loads the given
|
||||||
|
/// local model. Safe to call repeatedly — a call for the model that's already
|
||||||
|
/// ready/loading is a no-op; a call for a *different* model switches to it (the
|
||||||
|
/// previous one is dropped once no generation is still using it).
|
||||||
|
pub async fn ensure_loaded(&self, model_id: &str) {
|
||||||
|
{
|
||||||
|
let guard = self.inner.read().await;
|
||||||
|
let same_model = guard.model_id.as_deref() == Some(model_id);
|
||||||
|
if same_model && matches!(guard.phase, CommitAiPhase::Ready | CommitAiPhase::Loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(option) = find_local_model(model_id) else {
|
||||||
|
let mut guard = self.inner.write().await;
|
||||||
|
guard.phase = CommitAiPhase::Error;
|
||||||
|
guard.model_id = Some(model_id.to_string());
|
||||||
|
guard.error = Some(format!("Unknown local model: {model_id}"));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut guard = self.inner.write().await;
|
||||||
|
guard.phase = CommitAiPhase::Loading;
|
||||||
|
guard.model_id = Some(model_id.to_string());
|
||||||
|
guard.error = None;
|
||||||
|
guard.model = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = GgufModelBuilder::new(option.repo, vec![option.file])
|
||||||
|
.with_tok_model_id(option.tokenizer_repo)
|
||||||
|
.with_logging()
|
||||||
|
.build()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut guard = self.inner.write().await;
|
||||||
|
// If the user switched to yet another model while this one was loading, drop this
|
||||||
|
// (now stale) result instead of overwriting the newer request's state.
|
||||||
|
if guard.model_id.as_deref() != Some(model_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match result {
|
||||||
|
Ok(model) => {
|
||||||
|
guard.model = Some(Arc::new(model));
|
||||||
|
guard.phase = CommitAiPhase::Ready;
|
||||||
|
guard.error = None;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
guard.phase = CommitAiPhase::Error;
|
||||||
|
guard.error = Some(err.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn generate_commit_message(
|
||||||
|
&self,
|
||||||
|
diff: &str,
|
||||||
|
notes: Option<&str>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let model = {
|
||||||
|
let guard = self.inner.read().await;
|
||||||
|
match (guard.phase, &guard.model) {
|
||||||
|
(CommitAiPhase::Ready, Some(model)) => model.clone(),
|
||||||
|
_ => return Err("The local AI model is not ready yet.".to_string()),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (system, user) = build_messages(diff, notes)?;
|
||||||
|
let messages = TextMessages::new()
|
||||||
|
.add_message(TextMessageRole::System, system)
|
||||||
|
.add_message(TextMessageRole::User, user);
|
||||||
|
|
||||||
|
let response = model
|
||||||
|
.send_chat_request(messages)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let content = response
|
||||||
|
.choices
|
||||||
|
.first()
|
||||||
|
.and_then(|choice| choice.message.content.clone())
|
||||||
|
.ok_or_else(|| "The model did not return a response.".to_string())?;
|
||||||
|
|
||||||
|
let message = sanitize_message(&content);
|
||||||
|
if message.is_empty() {
|
||||||
|
return Err("The model did not return a response.".to_string());
|
||||||
|
}
|
||||||
|
Ok(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Models occasionally ignore the "no code fences" instruction (small local models
|
||||||
|
/// especially) — strip a wrapping ``` fence and wrapping quotes so the result can go
|
||||||
|
/// straight into the commit-message box.
|
||||||
|
pub(crate) fn sanitize_message(raw: &str) -> String {
|
||||||
|
let mut text = raw.trim().to_string();
|
||||||
|
if text.starts_with("```") {
|
||||||
|
text = match text.split_once('\n') {
|
||||||
|
// Drop the opening fence line (which may carry a language tag) and the closing fence.
|
||||||
|
Some((_fence, rest)) => rest.trim_end().trim_end_matches("```").trim().to_string(),
|
||||||
|
None => text.trim_matches('`').trim().to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
|
||||||
|
return trimmed[1..trimmed.len() - 1].trim().to_string();
|
||||||
|
}
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> {
|
||||||
|
if diff.trim().is_empty() {
|
||||||
|
return Err("No staged changes available for a commit message.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rough token estimate — small models often have an 8-32k context window.
|
||||||
|
const MAX_CHARS: usize = 24_000;
|
||||||
|
let diff = if diff.len() > 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. \
|
||||||
|
Respond only with the commit message in Conventional Commits format \
|
||||||
|
(<type>(<scope>): <subject>), followed by a body after a blank line. \
|
||||||
|
Subject in imperative mood, max. 72 characters. \
|
||||||
|
The body is required: summarize in a short paragraph what changed and why, \
|
||||||
|
then list the key changes as bullet points (- ...), \
|
||||||
|
grouped by affected area/file. Lines in the body max. 72 characters. \
|
||||||
|
No preamble, no explanation, no code fences, answer in English"
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
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 diff:\n{diff}"));
|
||||||
|
Ok((system, user))
|
||||||
|
}
|
||||||
+350
-128
File diff suppressed because it is too large
Load Diff
+14
-9
@@ -3,21 +3,22 @@
|
|||||||
mod git;
|
mod git;
|
||||||
|
|
||||||
use git::{
|
use git::{
|
||||||
apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, commit,
|
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
|
||||||
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, cred_delete,
|
checkout_branch, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models,
|
||||||
cred_load, cred_save, delete_branch, diff_file_against_working_tree, get_file_patch,
|
commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent,
|
||||||
get_remote_url, get_status, list_branches, list_commits, list_file_history,
|
create_branch, cred_delete, cred_load, cred_save, delete_branch,
|
||||||
list_repository_files, merge_branch, open_repo_in_explorer, open_repository,
|
diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, list_branches,
|
||||||
open_repository_bundle, open_repository_file, pull, push, read_conflict, rename_branch,
|
list_commits, list_file_history, list_repository_files, merge_branch, open_repo_in_explorer,
|
||||||
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
open_repository, open_repository_bundle, open_repository_file, pull, push, read_conflict,
|
||||||
restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
|
||||||
SearchCancellationState,
|
restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.manage(SearchCancellationState::default())
|
.manage(SearchCancellationState::default())
|
||||||
|
.manage(commit_ai::CommitAiEngine::new())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
open_repository,
|
open_repository,
|
||||||
@@ -35,6 +36,10 @@ fn main() {
|
|||||||
get_file_patch,
|
get_file_patch,
|
||||||
apply_file_patch,
|
apply_file_patch,
|
||||||
commit,
|
commit,
|
||||||
|
commit_ai_status,
|
||||||
|
commit_ai_load,
|
||||||
|
commit_ai_local_models,
|
||||||
|
commit_ai_generate,
|
||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
list_commits,
|
list_commits,
|
||||||
|
|||||||
+315
-17
@@ -5,11 +5,13 @@
|
|||||||
import { AlertCircle, BookOpen, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
|
import { AlertCircle, BookOpen, 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 BranchPanel from "./lib/components/BranchPanel.svelte";
|
import BranchPanel from "./lib/components/BranchPanel.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";
|
||||||
import CredentialDialog from "./lib/components/CredentialDialog.svelte";
|
import CredentialDialog from "./lib/components/CredentialDialog.svelte";
|
||||||
|
import DiscardConfirmDialog from "./lib/components/DiscardConfirmDialog.svelte";
|
||||||
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
||||||
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
|
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
|
||||||
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
|
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
|
||||||
@@ -25,6 +27,10 @@
|
|||||||
import {
|
import {
|
||||||
checkoutBranch,
|
checkoutBranch,
|
||||||
commit,
|
commit,
|
||||||
|
commitAiGenerate,
|
||||||
|
commitAiLoad,
|
||||||
|
commitAiLocalModels,
|
||||||
|
commitAiStatus,
|
||||||
compareCommits,
|
compareCommits,
|
||||||
cancelCodeSearch,
|
cancelCodeSearch,
|
||||||
cancelFileHistory,
|
cancelFileHistory,
|
||||||
@@ -62,6 +68,8 @@
|
|||||||
} from "./lib/git";
|
} from "./lib/git";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
AiSettings,
|
||||||
|
CommitAiPhase,
|
||||||
ConflictFile,
|
ConflictFile,
|
||||||
ExplorerNode,
|
ExplorerNode,
|
||||||
ExplorerNodeKind,
|
ExplorerNodeKind,
|
||||||
@@ -74,6 +82,7 @@
|
|||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
GitSearchHit,
|
GitSearchHit,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
|
LocalModelOption,
|
||||||
PatchApplyAction,
|
PatchApplyAction,
|
||||||
PreparedResolution,
|
PreparedResolution,
|
||||||
StoredCredential,
|
StoredCredential,
|
||||||
@@ -88,6 +97,9 @@
|
|||||||
|
|
||||||
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
||||||
type AppView = "management" | "repository";
|
type AppView = "management" | "repository";
|
||||||
|
type PendingDiscard =
|
||||||
|
| { kind: "file"; file: GitFileStatus; staged: boolean }
|
||||||
|
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
|
||||||
|
|
||||||
interface RepoTab {
|
interface RepoTab {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -101,6 +113,11 @@
|
|||||||
|
|
||||||
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 COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
|
||||||
|
const COMMIT_PANEL_DEFAULT_HEIGHT = 220;
|
||||||
|
const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT;
|
||||||
|
const COMMIT_PANEL_MAX_HEIGHT = 640;
|
||||||
|
|
||||||
// ── State ──────────────────────────────────────────────────────────────────
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -122,7 +139,14 @@
|
|||||||
let fileHistoryLoading = false;
|
let fileHistoryLoading = false;
|
||||||
let fileHistoryRequestId = 0;
|
let fileHistoryRequestId = 0;
|
||||||
let activeFileHistoryRequestId = "";
|
let activeFileHistoryRequestId = "";
|
||||||
|
let lastFileHistoryHeadHash = "";
|
||||||
let commitMessage = "";
|
let commitMessage = "";
|
||||||
|
let commitAiPhase: CommitAiPhase = "idle";
|
||||||
|
let commitAiGenerating = false;
|
||||||
|
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let aiSettings: AiSettings = defaultAiSettings();
|
||||||
|
let aiSettingsOpen = false;
|
||||||
|
let localModelOptions: LocalModelOption[] = [];
|
||||||
let errorMessage = "";
|
let errorMessage = "";
|
||||||
let operation = "";
|
let operation = "";
|
||||||
let compareFrom = "";
|
let compareFrom = "";
|
||||||
@@ -141,6 +165,7 @@
|
|||||||
let linePatchText = "";
|
let linePatchText = "";
|
||||||
let linePatchLoading = false;
|
let linePatchLoading = false;
|
||||||
let linePatchError = "";
|
let linePatchError = "";
|
||||||
|
let pendingDiscard: PendingDiscard | null = null;
|
||||||
let globalSearchOpen = false;
|
let globalSearchOpen = false;
|
||||||
let lastSearchQuery = "";
|
let lastSearchQuery = "";
|
||||||
let globalSearchResults: GitSearchHit[] = [];
|
let globalSearchResults: GitSearchHit[] = [];
|
||||||
@@ -170,6 +195,10 @@
|
|||||||
let updateCheckInFlight = false;
|
let updateCheckInFlight = false;
|
||||||
let updateDownloadTotal = 0;
|
let updateDownloadTotal = 0;
|
||||||
let updateDownloadedBytes = 0;
|
let updateDownloadedBytes = 0;
|
||||||
|
let commitPanelHeight = loadCommitPanelHeight();
|
||||||
|
let resizingCommitPanel = false;
|
||||||
|
let resizeStartY = 0;
|
||||||
|
let resizeStartHeight = 0;
|
||||||
|
|
||||||
// ── Derived ────────────────────────────────────────────────────────────────
|
// ── Derived ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -206,10 +235,12 @@
|
|||||||
loadRepoLists();
|
loadRepoLists();
|
||||||
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
|
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
|
||||||
void checkForUpdates();
|
void checkForUpdates();
|
||||||
|
void initCommitAi();
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
||||||
|
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
|
||||||
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
|
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -229,15 +260,110 @@
|
|||||||
applyStatus(nextStatus);
|
applyStatus(nextStatus);
|
||||||
// Something changed — reload branches, commits and files in one bundled call.
|
// Something changed — reload branches, commits and files in one bundled call.
|
||||||
const bundle = await openRepositoryBundle(activeRepoPath, 100);
|
const bundle = await openRepositoryBundle(activeRepoPath, 100);
|
||||||
|
const previousHeadHash = lastFileHistoryHeadHash;
|
||||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||||
await refreshFileHistory(activeRepoPath);
|
// File history reflects `git log`, which only changes when HEAD actually moves
|
||||||
|
// (new commit, checkout, merge, ...) — skip the reload otherwise so a plain
|
||||||
|
// working-tree/status change (staging, edits) doesn't keep re-fetching and
|
||||||
|
// flickering the currently viewed file's history.
|
||||||
|
if (lastFileHistoryHeadHash !== previousHeadHash) {
|
||||||
|
await refreshFileHistory(activeRepoPath);
|
||||||
|
}
|
||||||
} catch { /* ignore transient errors */ } finally {
|
} catch { /* ignore transient errors */ } finally {
|
||||||
autoRefreshInFlight = false;
|
autoRefreshInFlight = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Commit AI ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function stopCommitAiPolling() {
|
||||||
|
if (commitAiPollTimer) { clearInterval(commitAiPollTimer); commitAiPollTimer = undefined; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollCommitAiStatus() {
|
||||||
|
try {
|
||||||
|
const result = await commitAiStatus();
|
||||||
|
commitAiPhase = result.phase;
|
||||||
|
} catch { /* ignore transient errors */ }
|
||||||
|
if (commitAiPhase === "ready" || commitAiPhase === "error") stopCommitAiPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCommitAiPolling() {
|
||||||
|
// Only the local model has a download/load phase worth polling — cloud providers are
|
||||||
|
// plain API calls with nothing to wait for.
|
||||||
|
stopCommitAiPolling();
|
||||||
|
if (aiSettings.provider !== "local") return;
|
||||||
|
void pollCommitAiStatus();
|
||||||
|
commitAiPollTimer = setInterval(() => { void pollCommitAiStatus(); }, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initCommitAi() {
|
||||||
|
aiSettings = loadAiSettings();
|
||||||
|
try {
|
||||||
|
localModelOptions = await commitAiLocalModels();
|
||||||
|
} catch { /* AI features stay disabled if this fails; not fatal to the app */ }
|
||||||
|
if (aiSettings.provider === "local") {
|
||||||
|
try { await commitAiLoad(aiSettings.localModelId); } catch { /* surfaced via status polling */ }
|
||||||
|
}
|
||||||
|
startCommitAiPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAiSettings(next: AiSettings) {
|
||||||
|
const modelChanged = next.provider === "local" && next.localModelId !== aiSettings.localModelId;
|
||||||
|
aiSettings = next;
|
||||||
|
persistAiSettings(next);
|
||||||
|
aiSettingsOpen = false;
|
||||||
|
if (next.provider === "local" && (modelChanged || commitAiPhase === "idle")) {
|
||||||
|
commitAiPhase = "idle";
|
||||||
|
void commitAiLoad(next.localModelId);
|
||||||
|
}
|
||||||
|
startCommitAiPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateCommitMessageWithAi() {
|
||||||
|
if (!activeRepoPath || commitAiGenerating) return;
|
||||||
|
if (aiSettings.provider === "local" && commitAiPhase !== "ready") return;
|
||||||
|
commitAiGenerating = true;
|
||||||
|
errorMessage = "";
|
||||||
|
try {
|
||||||
|
const notes = commitMessage.trim() || undefined;
|
||||||
|
if (aiSettings.provider === "local") {
|
||||||
|
commitMessage = await commitAiGenerate(activeRepoPath, { provider: "local", notes });
|
||||||
|
} else if (aiSettings.provider === "openai") {
|
||||||
|
const cred = await credLoad("ai:openai");
|
||||||
|
commitMessage = await commitAiGenerate(activeRepoPath, {
|
||||||
|
provider: "openai",
|
||||||
|
notes,
|
||||||
|
model: aiSettings.openaiModel,
|
||||||
|
apiKey: cred?.password,
|
||||||
|
});
|
||||||
|
} else if (aiSettings.provider === "anthropic") {
|
||||||
|
const cred = await credLoad("ai:anthropic");
|
||||||
|
commitMessage = await commitAiGenerate(activeRepoPath, {
|
||||||
|
provider: "anthropic",
|
||||||
|
notes,
|
||||||
|
model: aiSettings.anthropicModel,
|
||||||
|
apiKey: cred?.password,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const cred = await credLoad("ai:custom");
|
||||||
|
commitMessage = await commitAiGenerate(activeRepoPath, {
|
||||||
|
provider: "custom",
|
||||||
|
notes,
|
||||||
|
model: aiSettings.customModel,
|
||||||
|
baseUrl: aiSettings.customBaseUrl,
|
||||||
|
apiKey: cred?.password,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = errorToMessage(error);
|
||||||
|
} finally {
|
||||||
|
commitAiGenerating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleAutoRefresh() {
|
function toggleAutoRefresh() {
|
||||||
autoRefreshEnabled = !autoRefreshEnabled;
|
autoRefreshEnabled = !autoRefreshEnabled;
|
||||||
if (autoRefreshEnabled) void autoRefreshTick();
|
if (autoRefreshEnabled) void autoRefreshTick();
|
||||||
@@ -398,6 +524,87 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultAiSettings(): AiSettings {
|
||||||
|
return {
|
||||||
|
provider: "local",
|
||||||
|
localModelId: "qwen2.5-1.5b",
|
||||||
|
openaiModel: "gpt-4o-mini",
|
||||||
|
anthropicModel: "claude-3-5-haiku-latest",
|
||||||
|
customBaseUrl: "",
|
||||||
|
customModel: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAiSettings(): AiSettings {
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(localStorage.getItem(AI_SETTINGS_KEY) ?? "null") as unknown;
|
||||||
|
if (stored && typeof stored === "object") {
|
||||||
|
return { ...defaultAiSettings(), ...(stored as Partial<AiSettings>) };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to defaults below.
|
||||||
|
}
|
||||||
|
return defaultAiSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistAiSettings(next: AiSettings) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(AI_SETTINGS_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
// Local storage is best-effort only; AI generation must keep working without it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampCommitPanelHeight(value: number): number {
|
||||||
|
return Math.min(COMMIT_PANEL_MAX_HEIGHT, Math.max(COMMIT_PANEL_MIN_HEIGHT, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCommitPanelHeight(): number {
|
||||||
|
try {
|
||||||
|
const stored = Number(localStorage.getItem(COMMIT_PANEL_HEIGHT_KEY));
|
||||||
|
if (Number.isFinite(stored) && stored > 0) return clampCommitPanelHeight(stored);
|
||||||
|
} catch {
|
||||||
|
// Fall through to the default below.
|
||||||
|
}
|
||||||
|
return COMMIT_PANEL_DEFAULT_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistCommitPanelHeight(value: number) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(COMMIT_PANEL_HEIGHT_KEY, String(value));
|
||||||
|
} catch {
|
||||||
|
// Local storage is best-effort only; resizing must keep working without it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCommitPanelResize(event: PointerEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
resizingCommitPanel = true;
|
||||||
|
resizeStartY = event.clientY;
|
||||||
|
resizeStartHeight = commitPanelHeight;
|
||||||
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCommitPanelResizeMove(event: PointerEvent) {
|
||||||
|
if (!resizingCommitPanel) return;
|
||||||
|
commitPanelHeight = clampCommitPanelHeight(resizeStartHeight + (resizeStartY - event.clientY));
|
||||||
|
}
|
||||||
|
|
||||||
|
function endCommitPanelResize(event: PointerEvent) {
|
||||||
|
if (!resizingCommitPanel) return;
|
||||||
|
resizingCommitPanel = false;
|
||||||
|
persistCommitPanelHeight(commitPanelHeight);
|
||||||
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCommitPanelResizeKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
||||||
|
event.preventDefault();
|
||||||
|
commitPanelHeight = clampCommitPanelHeight(commitPanelHeight + (event.key === "ArrowUp" ? 20 : -20));
|
||||||
|
persistCommitPanelHeight(commitPanelHeight);
|
||||||
|
}
|
||||||
|
|
||||||
function rememberRecentRepo(path: string) {
|
function rememberRecentRepo(path: string) {
|
||||||
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
|
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
|
||||||
persistRepoLists();
|
persistRepoLists();
|
||||||
@@ -430,6 +637,7 @@
|
|||||||
}
|
}
|
||||||
branches = [];
|
branches = [];
|
||||||
commits = [];
|
commits = [];
|
||||||
|
lastFileHistoryHeadHash = "";
|
||||||
repoFiles = [];
|
repoFiles = [];
|
||||||
selectedExplorerPath = "";
|
selectedExplorerPath = "";
|
||||||
selectedExplorerKind = "file";
|
selectedExplorerKind = "file";
|
||||||
@@ -520,6 +728,7 @@
|
|||||||
|
|
||||||
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 ?? "";
|
||||||
const hashes = new Set(commits.map((c) => c.hash));
|
const hashes = new Set(commits.map((c) => c.hash));
|
||||||
if (compareFrom && !hashes.has(compareFrom)) compareFrom = "";
|
if (compareFrom && !hashes.has(compareFrom)) compareFrom = "";
|
||||||
if (compareTo && !hashes.has(compareTo)) compareTo = "";
|
if (compareTo && !hashes.has(compareTo)) compareTo = "";
|
||||||
@@ -546,7 +755,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isCancellationMessage(message: string): boolean {
|
function isCancellationMessage(message: string): boolean {
|
||||||
return message.toLowerCase().includes("abgebrochen");
|
return message.toLowerCase().includes("cancelled");
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelActiveFileHistoryLoad() {
|
function cancelActiveFileHistoryLoad() {
|
||||||
@@ -620,7 +829,7 @@
|
|||||||
if (isBusy) return;
|
if (isBusy) return;
|
||||||
try {
|
try {
|
||||||
const selected = await openDialog({
|
const selected = await openDialog({
|
||||||
title: "Repository folder auswaehlen",
|
title: "Select repository folder",
|
||||||
directory: true,
|
directory: true,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
defaultPath: repoPath.trim() || activeRepoPath || undefined,
|
defaultPath: repoPath.trim() || activeRepoPath || undefined,
|
||||||
@@ -829,7 +1038,7 @@
|
|||||||
if (auth) {
|
if (auth) {
|
||||||
if (key) void credDelete(key).catch(() => {});
|
if (key) void credDelete(key).catch(() => {});
|
||||||
credDialogError =
|
credDialogError =
|
||||||
"Zugangsdaten wurden abgelehnt oder sind abgelaufen. Bitte erneut anmelden.";
|
"Credentials were rejected or have expired. Please sign in again.";
|
||||||
credDialogAction = action;
|
credDialogAction = action;
|
||||||
credDialogKey = key;
|
credDialogKey = key;
|
||||||
credDialogOpen = true;
|
credDialogOpen = true;
|
||||||
@@ -838,7 +1047,7 @@
|
|||||||
errorMessage = message;
|
errorMessage = message;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
credDialogError = message || "Anmeldung fehlgeschlagen.";
|
credDialogError = message || "Sign-in failed.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -876,11 +1085,11 @@
|
|||||||
if (errorMessage && !isAuthError(errorMessage) && isNonFastForwardPushError(errorMessage)) {
|
if (errorMessage && !isAuthError(errorMessage) && isNonFastForwardPushError(errorMessage)) {
|
||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
const shouldSync = window.confirm(
|
const shouldSync = window.confirm(
|
||||||
"Der Remote hat neuere Commits, deshalb wurde der Push abgelehnt.\n\nJetzt Pull/Merge ausfuehren und danach den Push erneut versuchen?",
|
"The remote has newer commits, so the push was rejected.\n\nRun Pull/Merge now and try pushing again afterwards?",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!shouldSync) {
|
if (!shouldSync) {
|
||||||
const message = "Push abgelehnt: Der Remote hat neuere Commits. Pull zuerst ausfuehren, dann erneut pushen.";
|
const message = "Push rejected: the remote has newer commits. Pull first, then push again.";
|
||||||
if (fromStore) errorMessage = message;
|
if (fromStore) errorMessage = message;
|
||||||
else credDialogError = message;
|
else credDialogError = message;
|
||||||
return;
|
return;
|
||||||
@@ -904,7 +1113,7 @@
|
|||||||
if (statusHasConflicts(status)) {
|
if (statusHasConflicts(status)) {
|
||||||
credDialogOpen = false;
|
credDialogOpen = false;
|
||||||
credDialogAction = null;
|
credDialogAction = null;
|
||||||
errorMessage = "Pull hat Merge-Konflikte erzeugt. Loese die Konflikte, committe den Merge und pushe danach erneut.";
|
errorMessage = "Pull produced merge conflicts. Resolve the conflicts, commit the merge, and then push again.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -979,7 +1188,12 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function discardFile(file: GitFileStatus, staged: boolean) {
|
function discardFile(file: GitFileStatus, staged: boolean) {
|
||||||
|
if (!activeRepoPath || isBusy) return;
|
||||||
|
pendingDiscard = { kind: "file", file, staged };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDiscardFile(file: GitFileStatus, staged: boolean) {
|
||||||
await runOperation(`Discarding ${file.path}`, async () => {
|
await runOperation(`Discarding ${file.path}`, async () => {
|
||||||
applyStatus(await restoreFiles(activeRepoPath, [file.path], staged));
|
applyStatus(await restoreFiles(activeRepoPath, [file.path], staged));
|
||||||
await refreshExplorerFiles(activeRepoPath);
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
@@ -1030,9 +1244,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyLinePatch(action: PatchApplyAction, patch: string) {
|
function isDiscardPatchAction(action: PatchApplyAction): boolean {
|
||||||
if (!activeRepoPath || !linePatchFile || isBusy) return;
|
return action === "discard-staged" || action === "discard-unstaged";
|
||||||
const file = linePatchFile;
|
}
|
||||||
|
|
||||||
|
async function runLinePatchAction(
|
||||||
|
action: PatchApplyAction,
|
||||||
|
patch: string,
|
||||||
|
file: GitFileStatus,
|
||||||
|
staged: boolean,
|
||||||
|
) {
|
||||||
|
if (!activeRepoPath || isBusy) return;
|
||||||
operation = patchOperationLabel(action, file);
|
operation = patchOperationLabel(action, file);
|
||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
linePatchError = "";
|
linePatchError = "";
|
||||||
@@ -1042,7 +1264,7 @@
|
|||||||
await refreshExplorerFiles(activeRepoPath);
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
await refreshFileHistory(activeRepoPath);
|
await refreshFileHistory(activeRepoPath);
|
||||||
|
|
||||||
const updatedPatch = await getFilePatch(activeRepoPath, file.path, linePatchStaged);
|
const updatedPatch = await getFilePatch(activeRepoPath, file.path, staged);
|
||||||
if (updatedPatch.trim()) {
|
if (updatedPatch.trim()) {
|
||||||
linePatchText = updatedPatch;
|
linePatchText = updatedPatch;
|
||||||
} else {
|
} else {
|
||||||
@@ -1058,6 +1280,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function applyLinePatch(action: PatchApplyAction, patch: string) {
|
||||||
|
if (!activeRepoPath || !linePatchFile || isBusy) return;
|
||||||
|
const file = linePatchFile;
|
||||||
|
const staged = linePatchStaged;
|
||||||
|
|
||||||
|
if (isDiscardPatchAction(action)) {
|
||||||
|
pendingDiscard = { kind: "hunk", file, staged, action, patch };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runLinePatchAction(action, patch, file, staged);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDiscard() {
|
||||||
|
const discard = pendingDiscard;
|
||||||
|
if (!discard || !activeRepoPath || isBusy) return;
|
||||||
|
|
||||||
|
if (discard.kind === "file") {
|
||||||
|
await runDiscardFile(discard.file, discard.staged);
|
||||||
|
} else {
|
||||||
|
await runLinePatchAction(discard.action, discard.patch, discard.file, discard.staged);
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDiscard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDiscardConfirm() {
|
||||||
|
if (isBusy) return;
|
||||||
|
pendingDiscard = null;
|
||||||
|
}
|
||||||
|
|
||||||
async function stageAllFiles() {
|
async function stageAllFiles() {
|
||||||
const paths = changedFiles.filter((f) => f.unstaged !== null).map((f) => f.path);
|
const paths = changedFiles.filter((f) => f.unstaged !== null).map((f) => f.path);
|
||||||
if (paths.length === 0) return;
|
if (paths.length === 0) return;
|
||||||
@@ -1295,7 +1548,7 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (globalSearchId === searchId) {
|
if (globalSearchId === searchId) {
|
||||||
const message = errorToMessage(error);
|
const message = errorToMessage(error);
|
||||||
globalSearchError = message.includes("abgebrochen") ? "Suche wurde abgebrochen." : message;
|
globalSearchError = message.includes("cancelled") ? "Search was cancelled." : message;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (globalSearchId === searchId) {
|
if (globalSearchId === searchId) {
|
||||||
@@ -1308,7 +1561,7 @@
|
|||||||
async function cancelGlobalSearch() {
|
async function cancelGlobalSearch() {
|
||||||
if (!globalSearchId) return;
|
if (!globalSearchId) return;
|
||||||
const searchId = globalSearchId;
|
const searchId = globalSearchId;
|
||||||
globalSearchError = "Abbruch wird angefordert...";
|
globalSearchError = "Requesting cancellation...";
|
||||||
try {
|
try {
|
||||||
await cancelCodeSearch(searchId);
|
await cancelCodeSearch(searchId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1387,7 +1640,8 @@
|
|||||||
// ── Event handlers ─────────────────────────────────────────────────────────
|
// ── Event handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function handleWindowKeydown(event: KeyboardEvent) {
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
|
if (event.key === "Escape" && pendingDiscard && !isBusy) closeDiscardConfirm();
|
||||||
|
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" && compareSelectOpen) compareSelectOpen = false;
|
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
|
||||||
@@ -1667,7 +1921,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="top-section">
|
<div class="top-section" style="--commit-panel-height: {commitPanelHeight}px;">
|
||||||
<StatusPanel
|
<StatusPanel
|
||||||
{changedFiles}
|
{changedFiles}
|
||||||
{stagedCount}
|
{stagedCount}
|
||||||
@@ -1684,6 +1938,24 @@
|
|||||||
onStageAll={stageAllFiles}
|
onStageAll={stageAllFiles}
|
||||||
onUnstageAll={unstageAllFiles}
|
onUnstageAll={unstageAllFiles}
|
||||||
/>
|
/>
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="panel-resize-handle"
|
||||||
|
class:resizing={resizingCommitPanel}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
aria-label="Resize commit panel height"
|
||||||
|
aria-valuenow={commitPanelHeight}
|
||||||
|
aria-valuemin={COMMIT_PANEL_MIN_HEIGHT}
|
||||||
|
aria-valuemax={COMMIT_PANEL_MAX_HEIGHT}
|
||||||
|
tabindex="0"
|
||||||
|
onpointerdown={startCommitPanelResize}
|
||||||
|
onpointermove={onCommitPanelResizeMove}
|
||||||
|
onpointerup={endCommitPanelResize}
|
||||||
|
onpointercancel={endCommitPanelResize}
|
||||||
|
onkeydown={onCommitPanelResizeKeydown}
|
||||||
|
></div>
|
||||||
<CommitPanel
|
<CommitPanel
|
||||||
{commitMessage}
|
{commitMessage}
|
||||||
{canCommit}
|
{canCommit}
|
||||||
@@ -1692,8 +1964,13 @@
|
|||||||
{isBusy}
|
{isBusy}
|
||||||
{operation}
|
{operation}
|
||||||
{stagedCount}
|
{stagedCount}
|
||||||
|
commitAiProvider={aiSettings.provider}
|
||||||
|
{commitAiPhase}
|
||||||
|
{commitAiGenerating}
|
||||||
onCommit={commitChanges}
|
onCommit={commitChanges}
|
||||||
onCommitMessageChange={(msg) => { commitMessage = msg; }}
|
onCommitMessageChange={(msg) => { commitMessage = msg; }}
|
||||||
|
onGenerateCommitMessage={generateCommitMessageWithAi}
|
||||||
|
onOpenAiSettings={() => { aiSettingsOpen = true; }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1757,6 +2034,17 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if pendingDiscard}
|
||||||
|
<DiscardConfirmDialog
|
||||||
|
file={pendingDiscard.file}
|
||||||
|
staged={pendingDiscard.staged}
|
||||||
|
scope={pendingDiscard.kind === "hunk" ? "hunk" : "file"}
|
||||||
|
{isBusy}
|
||||||
|
onConfirm={confirmDiscard}
|
||||||
|
onClose={closeDiscardConfirm}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if globalSearchOpen}
|
{#if globalSearchOpen}
|
||||||
<GlobalSearchDialog
|
<GlobalSearchDialog
|
||||||
{hasRepository}
|
{hasRepository}
|
||||||
@@ -1797,6 +2085,16 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Choose the AI provider/model used to generate commit messages -->
|
||||||
|
{#if aiSettingsOpen}
|
||||||
|
<AiSettingsDialog
|
||||||
|
settings={aiSettings}
|
||||||
|
localModels={localModelOptions}
|
||||||
|
onSave={saveAiSettings}
|
||||||
|
onClose={() => { aiSettingsOpen = false; }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Compare: pick the two commits to diff -->
|
<!-- Compare: pick the two commits to diff -->
|
||||||
{#if compareSelectOpen}
|
{#if compareSelectOpen}
|
||||||
<CompareSelectDialog
|
<CompareSelectDialog
|
||||||
|
|||||||
+177
-6
@@ -150,6 +150,19 @@
|
|||||||
background: linear-gradient(180deg, rgba(65, 209, 255, 0.18), rgba(100, 108, 255, 0.16));
|
background: linear-gradient(180deg, rgba(65, 209, 255, 0.18), rgba(100, 108, 255, 0.16));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
border-color: rgba(255, 90, 103, 0.72);
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 90, 103, 0.92), rgba(195, 44, 64, 0.9));
|
||||||
|
box-shadow: 0 0 22px rgba(255, 90, 103, 0.16);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
border-color: rgba(255, 161, 169, 0.86);
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(135deg, rgba(255, 111, 124, 0.96), rgba(214, 55, 77, 0.95));
|
||||||
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -940,13 +953,39 @@
|
|||||||
.top-section {
|
.top-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
grid-template-rows: minmax(0, 1fr) minmax(210px, auto);
|
grid-template-rows: minmax(120px, 1fr) 14px var(--commit-panel-height, 220px);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
gap: 8px;
|
gap: 0;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-resize-handle {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: row-resize;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.panel-resize-handle::before {
|
||||||
|
content: "";
|
||||||
|
width: 40px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-border);
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.panel-resize-handle:hover::before,
|
||||||
|
.panel-resize-handle.resizing::before {
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
.panel-resize-handle:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- File list / change lanes --- */
|
/* --- File list / change lanes --- */
|
||||||
|
|
||||||
.file-list { padding: 6px; overflow: auto; }
|
.file-list { padding: 6px; overflow: auto; }
|
||||||
@@ -1330,8 +1369,37 @@
|
|||||||
|
|
||||||
/* --- Commit form --- */
|
/* --- Commit form --- */
|
||||||
|
|
||||||
.commit-form { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; gap: 8px; padding: 10px; }
|
.commit-panel {
|
||||||
.commit-form textarea { flex: 1 1 0; min-height: 80px; resize: none; }
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.commit-panel .section-head { flex: 0 0 auto; }
|
||||||
|
.commit-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto auto;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.commit-form textarea {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
resize: none;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.commit-actions-row {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.commit-actions-row .btn-primary { min-width: 0; }
|
||||||
|
.commit-ai-button { flex: 0 0 auto; min-width: 64px; justify-content: center; }
|
||||||
|
.commit-ai-settings-button { flex: 0 0 auto; width: 38px; min-width: 38px; padding: 0; justify-content: center; }
|
||||||
.commit-block-reason {
|
.commit-block-reason {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@@ -1603,6 +1671,56 @@
|
|||||||
max-height: calc(100vh - 32px);
|
max-height: calc(100vh - 32px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
.discard-confirm-dialog {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
width: min(560px, calc(100vw - 32px));
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.ai-settings-dialog {
|
||||||
|
display: block;
|
||||||
|
width: min(560px, calc(100vw - 32px));
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.ai-settings-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.ai-provider-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.ai-provider-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
color: var(--color-ink-dim);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.ai-provider-option:hover {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
color: var(--color-ink);
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
.ai-provider-option.active {
|
||||||
|
border-color: rgba(100,108,255,0.5);
|
||||||
|
color: #f5f7ff;
|
||||||
|
background: linear-gradient(180deg, rgba(100,108,255,0.22), rgba(65,209,255,0.1));
|
||||||
|
}
|
||||||
.new-branch-form {
|
.new-branch-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1812,6 +1930,59 @@
|
|||||||
|
|
||||||
.prepared-tag { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; color: #4eca76; font-size: 12px; font-weight: 700; }
|
.prepared-tag { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; color: #4eca76; font-size: 12px; font-weight: 700; }
|
||||||
|
|
||||||
|
.discard-confirm-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px 16px 16px;
|
||||||
|
}
|
||||||
|
.discard-warning-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border: 1px solid rgba(255, 90, 103, 0.32);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #ff9aa4;
|
||||||
|
background: rgba(255, 90, 103, 0.1);
|
||||||
|
}
|
||||||
|
.discard-confirm-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.discard-confirm-copy p { margin: 0; }
|
||||||
|
.discard-target {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
max-height: 84px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px 9px;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-ink);
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.discard-warning-text {
|
||||||
|
color: #ffb8bf;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.discard-confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px 14px;
|
||||||
|
border-top: 1px solid var(--color-border-subtle);
|
||||||
|
background: var(--color-surface-dim);
|
||||||
|
}
|
||||||
|
|
||||||
.line-patch-body {
|
.line-patch-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: minmax(0, 1fr);
|
grid-template-rows: minmax(0, 1fr);
|
||||||
@@ -2848,7 +3019,7 @@
|
|||||||
/* 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) clamp(380px, 38vw, 500px); }
|
||||||
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 190px; }
|
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact: stack history panels vertically, narrow sidebars */
|
/* Compact: stack history panels vertically, narrow sidebars */
|
||||||
@@ -2874,7 +3045,7 @@
|
|||||||
.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(200px, 1fr) minmax(240px, 1.2fr); min-height: 440px; }
|
||||||
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 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; }
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Globe, Key, LoaderCircle, X } from "@lucide/svelte";
|
||||||
|
import { credDelete, credLoad, credSave } from "../git";
|
||||||
|
import type { AiSettings, CommitAiProvider, LocalModelOption } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: AiSettings;
|
||||||
|
localModels: LocalModelOption[];
|
||||||
|
onSave: (settings: AiSettings) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { settings, localModels = [], onSave, onClose }: Props = $props();
|
||||||
|
|
||||||
|
type CloudProvider = Exclude<CommitAiProvider, "local">;
|
||||||
|
|
||||||
|
const CRED_KEYS: Record<CloudProvider, string> = {
|
||||||
|
openai: "ai:openai",
|
||||||
|
anthropic: "ai:anthropic",
|
||||||
|
custom: "ai:custom",
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider = $state<CommitAiProvider>("local");
|
||||||
|
let localModelId = $state("");
|
||||||
|
let openaiModel = $state("");
|
||||||
|
let anthropicModel = $state("");
|
||||||
|
let customBaseUrl = $state("");
|
||||||
|
let customModel = $state("");
|
||||||
|
|
||||||
|
let openaiApiKey = $state("");
|
||||||
|
let anthropicApiKey = $state("");
|
||||||
|
let customApiKey = $state("");
|
||||||
|
let showKey = $state(false);
|
||||||
|
let loadingKeys = $state(true);
|
||||||
|
let saving = $state(false);
|
||||||
|
let error = $state("");
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
provider = settings.provider;
|
||||||
|
localModelId = settings.localModelId;
|
||||||
|
openaiModel = settings.openaiModel;
|
||||||
|
anthropicModel = settings.anthropicModel;
|
||||||
|
customBaseUrl = settings.customBaseUrl;
|
||||||
|
customModel = settings.customModel;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [openai, anthropic, custom] = await Promise.all([
|
||||||
|
credLoad(CRED_KEYS.openai),
|
||||||
|
credLoad(CRED_KEYS.anthropic),
|
||||||
|
credLoad(CRED_KEYS.custom),
|
||||||
|
]);
|
||||||
|
openaiApiKey = openai?.password ?? "";
|
||||||
|
anthropicApiKey = anthropic?.password ?? "";
|
||||||
|
customApiKey = custom?.password ?? "";
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
loadingKeys = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function persistKey(target: CloudProvider, value: string) {
|
||||||
|
const key = CRED_KEYS[target];
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
await credSave(key, "api-key", trimmed, null);
|
||||||
|
} else {
|
||||||
|
await credDelete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
saving = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
persistKey("openai", openaiApiKey),
|
||||||
|
persistKey("anthropic", anthropicApiKey),
|
||||||
|
persistKey("custom", customApiKey),
|
||||||
|
]);
|
||||||
|
onSave({
|
||||||
|
provider,
|
||||||
|
localModelId,
|
||||||
|
openaiModel: openaiModel.trim() || "gpt-4o-mini",
|
||||||
|
anthropicModel: anthropicModel.trim() || "claude-3-5-haiku-latest",
|
||||||
|
customBaseUrl: customBaseUrl.trim(),
|
||||||
|
customModel: customModel.trim(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(mb: number): string {
|
||||||
|
return mb >= 1000 ? `${(mb / 1000).toFixed(1)} GB` : `${mb} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedLocalModel = $derived(localModels.find((option) => option.id === localModelId));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="dialog-backdrop"
|
||||||
|
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">
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Commit AI</span>
|
||||||
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">AI settings</h2>
|
||||||
|
</div>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||||
|
<X size={18} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="ai-settings-form" onsubmit={(e) => { e.preventDefault(); void handleSave(); }}>
|
||||||
|
<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"; }}>
|
||||||
|
<Cpu size={16} aria-hidden="true" />
|
||||||
|
Local AI
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ai-provider-option" class:active={provider === "openai"} onclick={() => { provider = "openai"; }}>
|
||||||
|
<Bot size={16} aria-hidden="true" />
|
||||||
|
OpenAI
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ai-provider-option" class:active={provider === "anthropic"} onclick={() => { provider = "anthropic"; }}>
|
||||||
|
<Bot size={16} aria-hidden="true" />
|
||||||
|
Anthropic (Claude)
|
||||||
|
</button>
|
||||||
|
<button type="button" class="ai-provider-option" class:active={provider === "custom"} onclick={() => { provider = "custom"; }}>
|
||||||
|
<Globe size={16} aria-hidden="true" />
|
||||||
|
Custom endpoint
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if provider === "local"}
|
||||||
|
<label class="cred-field">
|
||||||
|
<span class="cred-field-label">Model</span>
|
||||||
|
<select bind:value={localModelId}>
|
||||||
|
{#each localModels as option (option.id)}
|
||||||
|
<option value={option.id}>{option.label} — {formatSize(option.approx_size_mb)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="cred-token-hint">
|
||||||
|
<AlertCircle size={13} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
Switching downloads the model{selectedLocalModel ? ` (${formatSize(selectedLocalModel.approx_size_mb)})` : ""}
|
||||||
|
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.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{:else if provider === "openai"}
|
||||||
|
<label class="cred-field">
|
||||||
|
<span class="cred-field-label">Model</span>
|
||||||
|
<input type="text" bind:value={openaiModel} placeholder="gpt-4o-mini" autocomplete="off" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
<div class="cred-field">
|
||||||
|
<span class="cred-field-label">API key</span>
|
||||||
|
<div class="cred-input">
|
||||||
|
<Key size={15} class="cred-field-icon" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
type={showKey ? "text" : "password"}
|
||||||
|
bind:value={openaiApiKey}
|
||||||
|
placeholder="sk-..."
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
disabled={loadingKeys}
|
||||||
|
/>
|
||||||
|
<button type="button" class="cred-reveal" tabindex="-1" onclick={() => { showKey = !showKey; }} aria-label={showKey ? "Hide" : "Show"}>
|
||||||
|
{#if showKey}<EyeOff size={14} aria-hidden="true" />{:else}<Eye size={14} aria-hidden="true" />{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if provider === "anthropic"}
|
||||||
|
<label class="cred-field">
|
||||||
|
<span class="cred-field-label">Model</span>
|
||||||
|
<input type="text" bind:value={anthropicModel} placeholder="claude-3-5-haiku-latest" autocomplete="off" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
<div class="cred-field">
|
||||||
|
<span class="cred-field-label">API key</span>
|
||||||
|
<div class="cred-input">
|
||||||
|
<Key size={15} class="cred-field-icon" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
type={showKey ? "text" : "password"}
|
||||||
|
bind:value={anthropicApiKey}
|
||||||
|
placeholder="sk-ant-..."
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
disabled={loadingKeys}
|
||||||
|
/>
|
||||||
|
<button type="button" class="cred-reveal" tabindex="-1" onclick={() => { showKey = !showKey; }} aria-label={showKey ? "Hide" : "Show"}>
|
||||||
|
{#if showKey}<EyeOff size={14} aria-hidden="true" />{:else}<Eye size={14} aria-hidden="true" />{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<label class="cred-field">
|
||||||
|
<span class="cred-field-label">Endpoint URL</span>
|
||||||
|
<input type="text" bind:value={customBaseUrl} placeholder="http://localhost:11434/v1" autocomplete="off" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
<label class="cred-field">
|
||||||
|
<span class="cred-field-label">Model</span>
|
||||||
|
<input type="text" bind:value={customModel} placeholder="llama3.1" autocomplete="off" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
<div class="cred-field">
|
||||||
|
<span class="cred-field-label">API key (optional)</span>
|
||||||
|
<div class="cred-input">
|
||||||
|
<Key size={15} class="cred-field-icon" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
type={showKey ? "text" : "password"}
|
||||||
|
bind:value={customApiKey}
|
||||||
|
placeholder="Optional"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
disabled={loadingKeys}
|
||||||
|
/>
|
||||||
|
<button type="button" class="cred-reveal" tabindex="-1" onclick={() => { showKey = !showKey; }} aria-label={showKey ? "Hide" : "Show"}>
|
||||||
|
{#if showKey}<EyeOff size={14} aria-hidden="true" />{:else}<Eye size={14} aria-hidden="true" />{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cred-token-hint">
|
||||||
|
<Globe size={13} aria-hidden="true" />
|
||||||
|
<span>For local OpenAI-compatible servers like Ollama or LM Studio. The base URL should end in /v1.</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="commit-block-reason">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="new-branch-actions">
|
||||||
|
<button class="btn-secondary" type="button" onclick={onClose} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button class="btn-primary" type="submit" disabled={saving || loadingKeys}>
|
||||||
|
{#if saving}
|
||||||
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<Check size={16} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, LoaderCircle } from "@lucide/svelte";
|
import { Check, LoaderCircle, Settings, Sparkles } from "@lucide/svelte";
|
||||||
|
import type { CommitAiPhase, CommitAiProvider } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
commitMessage: string;
|
commitMessage: string;
|
||||||
@@ -9,8 +10,13 @@
|
|||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
operation: string;
|
operation: string;
|
||||||
stagedCount: number;
|
stagedCount: number;
|
||||||
|
commitAiProvider: CommitAiProvider;
|
||||||
|
commitAiPhase: CommitAiPhase;
|
||||||
|
commitAiGenerating: boolean;
|
||||||
onCommit: () => void;
|
onCommit: () => void;
|
||||||
onCommitMessageChange: (msg: string) => void;
|
onCommitMessageChange: (msg: string) => void;
|
||||||
|
onGenerateCommitMessage: () => void;
|
||||||
|
onOpenAiSettings: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -21,17 +27,38 @@
|
|||||||
isBusy = false,
|
isBusy = false,
|
||||||
operation = "",
|
operation = "",
|
||||||
stagedCount = 0,
|
stagedCount = 0,
|
||||||
|
commitAiProvider = "local",
|
||||||
|
commitAiPhase = "idle",
|
||||||
|
commitAiGenerating = false,
|
||||||
onCommit = () => {},
|
onCommit = () => {},
|
||||||
onCommitMessageChange = () => {},
|
onCommitMessageChange = () => {},
|
||||||
|
onGenerateCommitMessage = () => {},
|
||||||
|
onOpenAiSettings = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
function handleSubmit(event: SubmitEvent) {
|
function handleSubmit(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onCommit();
|
onCommit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function aiButtonTitle(provider: CommitAiProvider, phase: CommitAiPhase, staged: number): string {
|
||||||
|
if (staged === 0) return "Stage changes first";
|
||||||
|
if (provider === "local" && phase === "loading") return "AI model is downloading/loading — this happens once";
|
||||||
|
if (provider === "local" && phase === "error") return "AI model failed to load — check AI settings";
|
||||||
|
return "Generate commit message with AI from the staged diff";
|
||||||
|
}
|
||||||
|
|
||||||
|
let localModelLoading = $derived(commitAiProvider === "local" && commitAiPhase === "loading");
|
||||||
|
let canGenerate = $derived(
|
||||||
|
hasRepository &&
|
||||||
|
!isBusy &&
|
||||||
|
!commitAiGenerating &&
|
||||||
|
stagedCount > 0 &&
|
||||||
|
(commitAiProvider !== "local" || commitAiPhase === "ready"),
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel flex flex-col" aria-label="Commit">
|
<section class="panel commit-panel" aria-label="Commit">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Commit</span>
|
<span class="eyebrow">Commit</span>
|
||||||
@@ -50,13 +77,39 @@
|
|||||||
{#if commitBlockReason}
|
{#if commitBlockReason}
|
||||||
<p class="commit-block-reason">{commitBlockReason}</p>
|
<p class="commit-block-reason">{commitBlockReason}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="btn-primary w-full flex-shrink-0" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}>
|
<div class="commit-actions-row flex-shrink-0">
|
||||||
{#if operation === "Committing"}
|
<button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}>
|
||||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
{#if operation === "Committing"}
|
||||||
{:else}
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||||
<Check size={16} aria-hidden="true" />
|
{:else}
|
||||||
{/if}
|
<Check size={16} aria-hidden="true" />
|
||||||
Commit
|
{/if}
|
||||||
</button>
|
Commit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-secondary commit-ai-button"
|
||||||
|
type="button"
|
||||||
|
onclick={onGenerateCommitMessage}
|
||||||
|
disabled={!canGenerate}
|
||||||
|
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
|
||||||
|
>
|
||||||
|
{#if commitAiGenerating || localModelLoading}
|
||||||
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<Sparkles size={16} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
AI
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-secondary commit-ai-settings-button"
|
||||||
|
type="button"
|
||||||
|
onclick={onOpenAiSettings}
|
||||||
|
disabled={isBusy}
|
||||||
|
title="AI settings"
|
||||||
|
aria-label="AI settings"
|
||||||
|
>
|
||||||
|
<Settings size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -44,10 +44,10 @@
|
|||||||
(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" : "Pull");
|
||||||
let actionTitle = $derived(action === "push" ? "Push authentifizieren" : "Pull authentifizieren");
|
let actionTitle = $derived(action === "push" ? "Authenticate push" : "Authenticate pull");
|
||||||
let actionHint = $derived(action === "push"
|
let actionHint = $derived(action === "push"
|
||||||
? "Der Remote braucht Schreibrechte. Nutze ein Passwort oder einen Token mit passenden Repository-Rechten."
|
? "The remote needs write access. Use a password or a token with the appropriate repository permissions."
|
||||||
: "Der Remote braucht Zugriff auf das Repository. Nutze deine Git-Zugangsdaten oder einen 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();
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
onclick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
||||||
>
|
>
|
||||||
<div class="cred-card" role="dialog" aria-modal="true" aria-label="Git-Zugangsdaten" 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">
|
||||||
<div class="cred-hero-top">
|
<div class="cred-hero-top">
|
||||||
<div class="cred-hero-icon">
|
<div class="cred-hero-icon">
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
<p class="cred-hero-label">{actionLabel} Remote</p>
|
<p class="cred-hero-label">{actionLabel} Remote</p>
|
||||||
<h2 class="cred-hero-title">{actionTitle}</h2>
|
<h2 class="cred-hero-title">{actionTitle}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button class="cred-close" type="button" onclick={onCancel} title="Abbrechen" aria-label="Abbrechen">
|
<button class="cred-close" type="button" onclick={onCancel} title="Cancel" aria-label="Cancel">
|
||||||
<X size={16} aria-hidden="true" />
|
<X size={16} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,12 +89,12 @@
|
|||||||
|
|
||||||
<div class="cred-security-note">
|
<div class="cred-security-note">
|
||||||
<ShieldCheck size={14} aria-hidden="true" />
|
<ShieldCheck size={14} aria-hidden="true" />
|
||||||
<span>Beim Speichern landet der Token verschluesselt im Schluesselbund des Betriebssystems – nie im Klartext.</span>
|
<span>When saved, the token is stored encrypted in the operating system's keychain — never in plain text.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="cred-body" onsubmit={handleSubmit}>
|
<form class="cred-body" onsubmit={handleSubmit}>
|
||||||
<div class="cred-segment" role="group" aria-label="Authentifizierungsart">
|
<div class="cred-segment" role="group" aria-label="Authentication method">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="cred-seg-btn"
|
class="cred-seg-btn"
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
aria-pressed={mode === "credentials"}
|
aria-pressed={mode === "credentials"}
|
||||||
>
|
>
|
||||||
<User size={13} aria-hidden="true" />
|
<User size={13} aria-hidden="true" />
|
||||||
Username + Passwort
|
Username + password
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
id="cred-username"
|
id="cred-username"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={username}
|
bind:value={username}
|
||||||
placeholder="z. B. mein-github-username"
|
placeholder="e.g. my-github-username"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
/>
|
/>
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
|
|
||||||
<div class="cred-field">
|
<div class="cred-field">
|
||||||
<label class="cred-field-label" for="cred-password">
|
<label class="cred-field-label" for="cred-password">
|
||||||
{mode === "token" ? "Token" : "Passwort"}
|
{mode === "token" ? "Token" : "Password"}
|
||||||
</label>
|
</label>
|
||||||
<div class="cred-input">
|
<div class="cred-input">
|
||||||
<Lock size={15} class="cred-field-icon" aria-hidden="true" />
|
<Lock size={15} class="cred-field-icon" aria-hidden="true" />
|
||||||
@@ -146,8 +146,8 @@
|
|||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
placeholder={mode === "token"
|
placeholder={mode === "token"
|
||||||
? "ghp_... oder anderer Zugangstoken"
|
? "ghp_... or another access token"
|
||||||
: "Passwort oder Personal Access Token"}
|
: "Password or personal access token"}
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
/>
|
/>
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
class="cred-reveal"
|
class="cred-reveal"
|
||||||
onclick={() => { showPassword = !showPassword; }}
|
onclick={() => { showPassword = !showPassword; }}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-label={showPassword ? "Verbergen" : "Anzeigen"}
|
aria-label={showPassword ? "Hide" : "Show"}
|
||||||
>
|
>
|
||||||
{#if showPassword}
|
{#if showPassword}
|
||||||
<EyeOff size={14} aria-hidden="true" />
|
<EyeOff size={14} aria-hidden="true" />
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
{#if mode === "token"}
|
{#if mode === "token"}
|
||||||
<div class="cred-token-hint">
|
<div class="cred-token-hint">
|
||||||
<Key size={13} aria-hidden="true" />
|
<Key size={13} aria-hidden="true" />
|
||||||
<span>Username wird automatisch auf <code>oauth2</code> gesetzt. Das funktioniert mit GitHub, GitLab und Bitbucket.</span>
|
<span>Username is automatically set to <code>oauth2</code>. This works with GitHub, GitLab, and Bitbucket.</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -184,26 +184,26 @@
|
|||||||
|
|
||||||
{#if saveSession}
|
{#if saveSession}
|
||||||
<div class="cred-expiry">
|
<div class="cred-expiry">
|
||||||
<label class="cred-field-label" for="cred-expiry">Ablaufdatum (optional)</label>
|
<label class="cred-field-label" for="cred-expiry">Expiration date (optional)</label>
|
||||||
<input
|
<input
|
||||||
id="cred-expiry"
|
id="cred-expiry"
|
||||||
type="date"
|
type="date"
|
||||||
bind:value={expiresAt}
|
bind:value={expiresAt}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
/>
|
/>
|
||||||
<span class="cred-expiry-hint">Nach diesem Datum wird automatisch erneut nach dem Login gefragt.</span>
|
<span class="cred-expiry-hint">After this date you'll automatically be asked to log in again.</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="cred-footer">
|
<div class="cred-footer">
|
||||||
<label class="cred-save">
|
<label class="cred-save">
|
||||||
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
|
<input type="checkbox" bind:checked={saveSession} disabled={isBusy} />
|
||||||
<span>Im Schluesselbund speichern</span>
|
<span>Save in keychain</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="cred-btns">
|
<div class="cred-btns">
|
||||||
<button type="button" class="cred-cancel" onclick={onCancel} disabled={isBusy}>
|
<button type="button" class="cred-cancel" onclick={onCancel} disabled={isBusy}>
|
||||||
Abbrechen
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button class="cred-submit" type="submit" disabled={!canSubmit}>
|
<button class="cred-submit" type="submit" disabled={!canSubmit}>
|
||||||
{#if isBusy}
|
{#if isBusy}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertTriangle, LoaderCircle, RotateCcw, X } from "@lucide/svelte";
|
||||||
|
import type { GitFileStatus } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
file: GitFileStatus;
|
||||||
|
staged: boolean;
|
||||||
|
scope: "file" | "hunk";
|
||||||
|
isBusy: boolean;
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
file,
|
||||||
|
staged = false,
|
||||||
|
scope = "file",
|
||||||
|
isBusy = false,
|
||||||
|
onConfirm = () => {},
|
||||||
|
onClose = () => {},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let targetPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
||||||
|
let title = $derived(scope === "hunk" ? "Discard hunk?" : "Discard file changes?");
|
||||||
|
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
|
||||||
|
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
|
||||||
|
|
||||||
|
function closeFromBackdrop(event: MouseEvent) {
|
||||||
|
if (isBusy || event.target !== event.currentTarget) return;
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dialog-backdrop" role="presentation" onclick={closeFromBackdrop}>
|
||||||
|
<div class="dialog discard-confirm-dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||||||
|
<header class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Confirm discard</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="discard-confirm-body">
|
||||||
|
<div class="discard-warning-icon" aria-hidden="true">
|
||||||
|
<AlertTriangle size={22} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="discard-confirm-copy">
|
||||||
|
<p>
|
||||||
|
This will reset the {sourceLabel} for the {scopeLabel.toLowerCase()} below.
|
||||||
|
</p>
|
||||||
|
<code class="discard-target" title={targetPath}>{targetPath}</code>
|
||||||
|
<p class="discard-warning-text">
|
||||||
|
This cannot be undone. If the file only exists in your working tree, it can be deleted entirely.
|
||||||
|
</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}
|
||||||
|
<RotateCcw size={15} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let label = "Repository wird geöffnet";
|
export let label = "Opening repository";
|
||||||
export let repoName = "";
|
export let repoName = "";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
CommitAiProvider,
|
||||||
|
CommitAiStatus,
|
||||||
ConflictFile,
|
ConflictFile,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
GitCommit,
|
GitCommit,
|
||||||
@@ -8,6 +10,7 @@ import type {
|
|||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
GitSearchHit,
|
GitSearchHit,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
|
LocalModelOption,
|
||||||
PatchApplyAction,
|
PatchApplyAction,
|
||||||
RepositoryBundle,
|
RepositoryBundle,
|
||||||
StoredCredential,
|
StoredCredential,
|
||||||
@@ -94,6 +97,37 @@ export function commit(path: string, message: string): Promise<GitStatus> {
|
|||||||
return invoke<GitStatus>("commit", { path, message });
|
return invoke<GitStatus>("commit", { path, message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function commitAiStatus(): Promise<CommitAiStatus> {
|
||||||
|
return invoke<CommitAiStatus>("commit_ai_status");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commitAiLoad(modelId: string): Promise<void> {
|
||||||
|
return invoke<void>("commit_ai_load", { modelId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commitAiLocalModels(): Promise<LocalModelOption[]> {
|
||||||
|
return invoke<LocalModelOption[]>("commit_ai_local_models");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommitAiGenerateOptions {
|
||||||
|
notes?: string;
|
||||||
|
provider: CommitAiProvider;
|
||||||
|
model?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commitAiGenerate(path: string, options: CommitAiGenerateOptions): Promise<string> {
|
||||||
|
return invoke<string>("commit_ai_generate", {
|
||||||
|
path,
|
||||||
|
notes: options.notes,
|
||||||
|
provider: options.provider,
|
||||||
|
model: options.model,
|
||||||
|
apiKey: options.apiKey,
|
||||||
|
baseUrl: options.baseUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function pull(path: string, username?: string, password?: string): Promise<GitStatus> {
|
export function pull(path: string, username?: string, password?: string): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null });
|
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,30 @@ export type FileStatusKind =
|
|||||||
| "conflicted"
|
| "conflicted"
|
||||||
| "unknown";
|
| "unknown";
|
||||||
|
|
||||||
|
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
|
||||||
|
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
|
||||||
|
|
||||||
|
export interface CommitAiStatus {
|
||||||
|
phase: CommitAiPhase;
|
||||||
|
model_id: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalModelOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
approx_size_mb: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiSettings {
|
||||||
|
provider: CommitAiProvider;
|
||||||
|
localModelId: string;
|
||||||
|
openaiModel: string;
|
||||||
|
anthropicModel: string;
|
||||||
|
customBaseUrl: string;
|
||||||
|
customModel: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitStatus {
|
export interface GitStatus {
|
||||||
repo_path: string;
|
repo_path: string;
|
||||||
current_branch: string | null;
|
current_branch: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user