Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 127 additions & 94 deletions apps/desktop/dev-server.mjs

Large diffs are not rendered by default.

104 changes: 92 additions & 12 deletions apps/desktop/src-tauri/src/commands/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1610,7 +1610,6 @@ pub(crate) fn git_worktree_list(cwd: String) -> Result<Vec<WorktreeEntry>, Strin
let stdout = String::from_utf8_lossy(&output.stdout);
let mut entries: Vec<WorktreeEntry> = Vec::new();
let mut current: Option<WorktreeEntry> = None;
let mut is_first = true;

for line in stdout.lines() {
if line.starts_with("worktree ") {
Expand All @@ -1622,13 +1621,18 @@ pub(crate) fn git_worktree_list(cwd: String) -> Result<Vec<WorktreeEntry>, Strin
path,
branch: String::new(),
head: String::new(),
is_main: is_first,
is_main: false,
is_locked: false,
lock_reason: None,
is_bare: false,
is_prunable: false,
prunable_reason: None,
});
is_first = false;
} else if let Some(ref mut e) = current {
if line.starts_with("HEAD ") {
if line == "main" {
// Attribut explicite depuis git 2.36
e.is_main = true;
} else if line.starts_with("HEAD ") {
e.head = line["HEAD ".len()..].to_string();
} else if line.starts_with("branch ") {
let full = &line["branch ".len()..];
Expand All @@ -1637,15 +1641,32 @@ pub(crate) fn git_worktree_list(cwd: String) -> Result<Vec<WorktreeEntry>, Strin
e.is_bare = true;
} else if line.starts_with("locked") {
e.is_locked = true;
// Format : "locked" seul ou "locked <raison>" avec raison inline
let reason = line["locked".len()..].trim();
if !reason.is_empty() {
e.lock_reason = Some(reason.to_string());
}
} else if line.starts_with("prunable") {
e.is_prunable = true;
let reason = line["prunable".len()..].trim();
if !reason.is_empty() {
e.prunable_reason = Some(reason.to_string());
}
} else if line == "detached" {
e.branch = "(detached HEAD)".to_string();
}
}
}
if let Some(e) = current {
if let Some(e) = current.take() {
entries.push(e);
}

// Fallback pour git < 2.36 : l'attribut "main" n'existait pas.
// Si aucune entrée n'est marquée is_main, on marque la première.
if !entries.is_empty() && entries.iter().all(|e| !e.is_main) {
entries[0].is_main = true;
}

Ok(entries)
}

Expand All @@ -1656,6 +1677,15 @@ pub(crate) fn git_worktree_add(
branch: String,
new_branch: Option<String>,
) -> Result<WorktreeEntry, String> {
// Note, create folders if they dont exist.
let target_path = std::path::Path::new(&path);
if let Some(parent) = target_path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create worktree base directory: {}", e))?;
}
}

let mut cmd = git_cmd();
cmd.arg("worktree").arg("add").arg(&path);

Expand All @@ -1678,13 +1708,28 @@ pub(crate) fn git_worktree_add(
}

let resolved_branch = new_branch.as_deref().unwrap_or(&branch).to_string();

// Récupérer le SHA HEAD réel depuis le nouveau worktree
let head = git_cmd()
.args(["rev-parse", "HEAD"])
.current_dir(&path)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_default();

Ok(WorktreeEntry {
path,
branch: resolved_branch,
head: String::new(),
head,
is_main: false,
is_locked: false,
lock_reason: None,
is_bare: false,
is_prunable: false,
prunable_reason: None,
})
}

Expand Down Expand Up @@ -1746,12 +1791,16 @@ pub(crate) fn git_worktree_status_all(cwd: String) -> Result<Vec<WorkspaceRepoSt
.map(|s| s.trim().to_string())
.unwrap_or_default();

let (ahead, behind) = git_cmd()
// Upstream : détecter si une remote est configurée, et extraire ahead/behind
let upstream_out = git_cmd()
.args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
.current_dir(&path)
.output()
.ok()
.filter(|o| o.status.success())
.filter(|o| o.status.success());

let has_upstream = upstream_out.is_some();
let (ahead, behind) = upstream_out
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| {
let parts: Vec<&str> = s.trim().split_whitespace().collect();
Expand All @@ -1761,22 +1810,53 @@ pub(crate) fn git_worktree_status_all(cwd: String) -> Result<Vec<WorkspaceRepoSt
})
.unwrap_or((0, 0));

let modified = git_cmd()
// Status : séparer les fichiers en conflit (UU/AA/DD/AU/UA/DU/UD) des simples modifiés
let status_out = git_cmd()
.args(["status", "--porcelain", "--untracked-files=no"])
.current_dir(&path)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.lines().filter(|l| !l.is_empty()).count() as u32)
.unwrap_or(0);
.unwrap_or_default();

const CONFLICT_CODES: &[&str] = &["UU", "AA", "DD", "AU", "UA", "DU", "UD"];
let conflicted = status_out
.lines()
.filter(|l| l.len() >= 2 && CONFLICT_CODES.contains(&&l[..2]))
.count() as u32;
let modified = status_out
.lines()
.filter(|l| l.len() >= 2 && !CONFLICT_CODES.contains(&&l[..2]))
.count() as u32;

WorkspaceRepoStatus { path, name, branch, ahead, behind, modified, error: None }
WorkspaceRepoStatus { path, name, branch, ahead, behind, has_upstream, modified, conflicted, error: None }
}).collect();

Ok(statuses)
}

#[tauri::command]
pub(crate) fn git_worktree_repair(cwd: String, paths: Vec<String>) -> Result<(), String> {
let mut cmd = git_cmd();
cmd.args(["worktree", "repair"]);
for p in &paths {
cmd.arg(p);
}
let output = cmd
.current_dir(&cwd)
.output()
.map_err(|e| format!("Failed to repair worktrees: {}", e))?;

if !output.status.success() {
return Err(format!(
"git worktree repair failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(())
}

// ─── Git clone / fork ─────────────────────────────────────────

// ─── Clone progress helpers ──────────────────────────────────
Expand Down
14 changes: 12 additions & 2 deletions apps/desktop/src-tauri/src/commands/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,20 @@ pub(crate) fn workspace_status_all(repos: Vec<WorkspaceRepo>) -> Vec<WorkspaceRe
let path = repo.path.clone();
let name = repo.name.clone();

let (branch, ahead, behind, _no_upstream) = libgit2_branch_ab(&path);
let (branch, ahead, behind, no_upstream) = libgit2_branch_ab(&path);
let modified = libgit2_modified_count(&path);

WorkspaceRepoStatus { path, name, branch, ahead, behind, modified, error: None }
WorkspaceRepoStatus {
path,
name,
branch,
ahead,
behind,
has_upstream: !no_upstream,
modified,
conflicted: 0, // non calculé dans la vue workspace (libgit2 ne distingue pas les conflits ici)
error: None,
}
}).collect()
}

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ pub fn run() {
commands::ops::git_worktree_add,
commands::ops::git_worktree_remove,
commands::ops::git_worktree_prune,
commands::ops::git_worktree_repair,
commands::ops::agent_session_list,
commands::ops::agent_session_launch,
commands::ops::git_submodule_list,
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,9 @@ pub struct WorkspaceRepoStatus {
pub branch: String,
pub ahead: u32,
pub behind: u32,
pub has_upstream: bool,
pub modified: u32,
pub conflicted: u32,
pub error: Option<String>,
}

Expand Down Expand Up @@ -673,7 +675,10 @@ pub struct WorktreeEntry {
pub head: String,
pub is_main: bool,
pub is_locked: bool,
pub lock_reason: Option<String>,
pub is_bare: bool,
pub is_prunable: bool,
pub prunable_reason: Option<String>,
}

// ─── Agent session types ──────────────────────────────────────────
Expand Down
13 changes: 8 additions & 5 deletions apps/desktop/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ const {
applyStash: applyStashRepo,
popStash: popStashRepo,
dropStash,
worktreeBranches,
} = useGitRepo();

function switchToChangesWithFirstFile() {
Expand Down Expand Up @@ -2189,7 +2190,7 @@ onUnmounted(() => {
:main-commit-count="mainCommitCount" :push-remote="pushRemote"
:ahead-push-count="aheadPushCount" :is-pushing="isPushing" :is-pulling="isPulling"
:force-push-preferred="forcePushPreferred" :is-fetching="isFetching"
:cwd="repoFolderPath ?? ''" :branches="branches" :branches-loading="branchesLoading"
:cwd="repoFolderPath ?? ''" :branches="branches" :worktree-branches="worktreeBranches" :branches-loading="branchesLoading"
:is-switching-branch="isSwitchingBranch" :is-merging="isMerging" :tabs="repoTabs" :active-tab-id="activeTabId"
@open-folder="handleOpenFolder" @open-repo="handleOpenPath" @switch-tab="switchTab" @close-tab="closeTab"
@reorder-tabs="reorderTabs"
Expand Down Expand Up @@ -2355,7 +2356,7 @@ onUnmounted(() => {
<aside v-if="showGitTree && hasRepo" class="git-tree-panel"
:style="{ width: gitTreeWidth + 'px', minWidth: gitTreeWidth + 'px' }">
<CommitGraph :commits="repoLog" :selected-hash="selectedCommitHash" :current-branch="repoStatus?.branch"
:fork-point-sha="graphForkPointSha" :repo-stats="repoStats" :branches="branches" :stashes="stashes"
:fork-point-sha="graphForkPointSha" :repo-stats="repoStats" :branches="branches" :worktree-branches="worktreeBranches" :stashes="stashes"
:submodule-changes="submoduleChanges"
:has-more="logHasMore" :loading-more="logLoadingMore"
@select-commit="(hash) => { selectCommit(hash); viewMode = 'history'; }"
Expand Down Expand Up @@ -2463,6 +2464,7 @@ onUnmounted(() => {
<WorktreeManager v-if="showWorktrees && repoFolderPath" :cwd="repoFolderPath" :branches="branches"
:suggested-branch="pendingWorktreeBranch" :open-quick-create="pendingQuickCreate"
@close="showWorktrees = false; pendingWorktreeBranch = undefined; pendingQuickCreate = false;"
@load-branches="loadBranches"
@open-tab="(path) => { openTab(path); showWorktrees = false; pendingWorktreeBranch = undefined; pendingQuickCreate = false; }" />

<!-- Submodule panel (uses BaseModal internally → own Teleport + backdrop) -->
Expand Down Expand Up @@ -2585,9 +2587,10 @@ onUnmounted(() => {
<!-- Command palette (Cmd/Ctrl+K) — teleports to body, so position
in the template tree is cosmetic. Mounted conditionally so the
input gets fresh autofocus each time it opens. -->
<SearchPalette v-if="showSearchPalette" :branches="branches" :commits="repoLog" :actions="paletteActions"
@close="showSearchPalette = false" @switch-branch="onPaletteSwitchBranch" @select-commit="onPaletteSelectCommit"
@run-action="onPaletteAction" />
<SearchPalette v-if="showSearchPalette" :branches="branches" :worktree-branches="worktreeBranches" :commits="repoLog" :actions="paletteActions"
@close="showSearchPalette = false" @switch-branch="onPaletteSwitchBranch"
@select-commit="onPaletteSelectCommit" @run-action="onPaletteAction"
@load-branches="loadBranches" @load-log="loadLog" />

<!-- Rename / Delete-branch modals, raised from BranchMenu.
Both teleport to body and guard against `repoStatus?.branch` going
Expand Down
Loading
Loading