From e7008761640703d05f7ac57813de57f5b7069007 Mon Sep 17 00:00:00 2001 From: Samigos Date: Wed, 11 Feb 2026 01:38:36 +0200 Subject: [PATCH 1/8] Git: init repo with GitHub remote creation --- src-tauri/src/bin/codex_monitor_daemon.rs | 20 + src-tauri/src/bin/codex_monitor_daemon/rpc.rs | 15 + src-tauri/src/git/mod.rs | 37 ++ src-tauri/src/lib.rs | 2 + src-tauri/src/shared/git_ui_core.rs | 353 ++++++++++++++++++ src/App.tsx | 46 ++- src/features/app/components/AppModals.tsx | 47 +++ .../git/components/GitDiffPanel.test.tsx | 29 +- src/features/git/components/GitDiffPanel.tsx | 6 + .../git/components/GitDiffPanel.utils.ts | 15 +- .../components/GitDiffPanelModeContent.tsx | 41 +- .../git/components/InitGitRepoPrompt.tsx | 192 ++++++++++ src/features/git/hooks/useGitActions.ts | 138 +++++++ src/features/git/hooks/useGitRemote.ts | 61 +-- .../git/hooks/useInitGitRepoPrompt.ts | 206 ++++++++++ src/features/git/utils/repoErrors.ts | 21 ++ .../hooks/layoutNodes/buildGitNodes.tsx | 2 + .../layout/hooks/layoutNodes/types.ts | 2 + .../workspaces/components/WorkspaceHome.tsx | 14 + .../WorkspaceHomeGitInitBanner.test.tsx | 22 ++ .../components/WorkspaceHomeGitInitBanner.tsx | 28 ++ src/services/tauri.test.ts | 15 + src/services/tauri.ts | 37 ++ src/styles/ds-modal.css | 2 +- src/styles/ds-tokens.css | 4 + src/styles/error-toasts.css | 2 +- src/styles/git-init-modal.css | 57 +++ src/styles/mobile-setup-wizard.css | 2 +- src/styles/workspace-home.css | 27 ++ 29 files changed, 1396 insertions(+), 47 deletions(-) create mode 100644 src/features/git/components/InitGitRepoPrompt.tsx create mode 100644 src/features/git/hooks/useInitGitRepoPrompt.ts create mode 100644 src/features/git/utils/repoErrors.ts create mode 100644 src/features/workspaces/components/WorkspaceHomeGitInitBanner.test.tsx create mode 100644 src/features/workspaces/components/WorkspaceHomeGitInitBanner.tsx create mode 100644 src/styles/git-init-modal.css diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 90b9dcb1b..c866d7592 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -890,6 +890,26 @@ impl DaemonState { git_ui_core::get_git_status_core(&self.workspaces, workspace_id).await } + async fn init_git_repo( + &self, + workspace_id: String, + branch: String, + force: bool, + ) -> Result { + git_ui_core::init_git_repo_core(&self.workspaces, workspace_id, branch, force).await + } + + async fn create_github_repo( + &self, + workspace_id: String, + repo: String, + visibility: String, + branch: Option, + ) -> Result { + git_ui_core::create_github_repo_core(&self.workspaces, workspace_id, repo, visibility, branch) + .await + } + async fn list_git_roots( &self, workspace_id: String, diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc.rs index ef3c2a345..8be5e776a 100644 --- a/src-tauri/src/bin/codex_monitor_daemon/rpc.rs +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc.rs @@ -504,6 +504,21 @@ pub(super) async fn handle_rpc_request( let workspace_id = parse_string(¶ms, "workspaceId")?; state.get_git_status(workspace_id).await } + "init_git_repo" => { + let workspace_id = parse_string(¶ms, "workspaceId")?; + let branch = parse_string(¶ms, "branch")?; + let force = parse_optional_bool(¶ms, "force").unwrap_or(false); + state.init_git_repo(workspace_id, branch, force).await + } + "create_github_repo" => { + let workspace_id = parse_string(¶ms, "workspaceId")?; + let repo = parse_string(¶ms, "repo")?; + let visibility = parse_string(¶ms, "visibility")?; + let branch = parse_optional_string(¶ms, "branch"); + state + .create_github_repo(workspace_id, repo, visibility, branch) + .await + } "list_git_roots" => { let workspace_id = parse_string(¶ms, "workspaceId")?; let depth = parse_optional_u32(¶ms, "depth").map(|value| value as usize); diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index 5b2b2b1c1..a3da3f522 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -84,6 +84,43 @@ pub(crate) async fn get_git_status( git_ui_core::get_git_status_core(&state.workspaces, workspace_id).await } +#[tauri::command] +pub(crate) async fn init_git_repo( + workspace_id: String, + branch: String, + force: Option, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + try_remote_value!( + state, + app, + "init_git_repo", + json!({ "workspaceId": &workspace_id, "branch": &branch, "force": force }) + ); + git_ui_core::init_git_repo_core(&state.workspaces, workspace_id, branch, force.unwrap_or(false)) + .await +} + +#[tauri::command] +pub(crate) async fn create_github_repo( + workspace_id: String, + repo: String, + visibility: String, + branch: Option, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + try_remote_value!( + state, + app, + "create_github_repo", + json!({ "workspaceId": &workspace_id, "repo": &repo, "visibility": &visibility, "branch": branch }) + ); + git_ui_core::create_github_repo_core(&state.workspaces, workspace_id, repo, visibility, branch) + .await +} + #[tauri::command] pub(crate) async fn stage_git_file( workspace_id: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1c9a6b4de..8c0f3833c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -252,6 +252,8 @@ pub fn run() { codex::collaboration_mode_list, workspaces::connect_workspace, git::get_git_status, + git::init_git_repo, + git::create_github_repo, git::list_git_roots, git::get_git_diffs, git::get_git_log, diff --git a/src-tauri/src/shared/git_ui_core.rs b/src-tauri/src/shared/git_ui_core.rs index 5d5716e58..da58e9cd3 100644 --- a/src-tauri/src/shared/git_ui_core.rs +++ b/src-tauri/src/shared/git_ui_core.rs @@ -110,6 +110,42 @@ async fn run_git_command(repo_root: &Path, args: &[&str]) -> Result<(), String> Err(detail.to_string()) } +async fn run_gh_command(repo_root: &Path, args: &[&str]) -> Result<(String, String), String> { + let output = tokio_command("gh") + .args(args) + .current_dir(repo_root) + .output() + .await + .map_err(|e| format!("Failed to run gh: {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if output.status.success() { + return Ok((stdout, stderr)); + } + + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + if detail.is_empty() { + return Err("GitHub CLI command failed.".to_string()); + } + Err(detail.to_string()) +} + +async fn gh_stdout_trim(repo_root: &Path, args: &[&str]) -> Result { + let (stdout, _) = run_gh_command(repo_root, args).await?; + Ok(stdout.trim().to_string()) +} + +async fn gh_git_protocol(repo_root: &Path) -> String { + gh_stdout_trim(repo_root, &["config", "get", "git_protocol"]) + .await + .unwrap_or_else(|_| "https".to_string()) +} + fn action_paths_for_file(repo_root: &Path, path: &str) -> Vec { let target = normalize_git_path(path).trim().to_string(); if target.is_empty() { @@ -777,6 +813,304 @@ async fn get_git_status_inner( })) } +fn count_effective_dir_entries(root: &Path) -> Result { + let entries = fs::read_dir(root).map_err(|err| format!("Failed to read directory: {err}"))?; + let mut count = 0usize; + for entry in entries { + let entry = + entry.map_err(|err| format!("Failed to read directory entry in {}: {err}", root.display()))?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name == ".git" || name == ".DS_Store" || name == "Thumbs.db" { + continue; + } + count += 1; + } + Ok(count) +} + +fn validate_branch_name(name: &str) -> Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("Branch name is required.".to_string()); + } + if trimmed == "." || trimmed == ".." { + return Err("Branch name cannot be '.' or '..'.".to_string()); + } + if trimmed.chars().any(|ch| ch.is_whitespace()) { + return Err("Branch name cannot contain spaces.".to_string()); + } + if trimmed.starts_with('/') || trimmed.ends_with('/') { + return Err("Branch name cannot start or end with '/'.".to_string()); + } + if trimmed.ends_with(".lock") { + return Err("Branch name cannot end with '.lock'.".to_string()); + } + if trimmed.contains("..") { + return Err("Branch name cannot contain '..'.".to_string()); + } + if trimmed.contains("@{") { + return Err("Branch name cannot contain '@{'.".to_string()); + } + let invalid_chars = ['~', '^', ':', '?', '*', '[', '\\']; + if trimmed.chars().any(|ch| invalid_chars.contains(&ch)) { + return Err("Branch name contains invalid characters.".to_string()); + } + if trimmed.ends_with('.') { + return Err("Branch name cannot end with '.'.".to_string()); + } + Ok(trimmed.to_string()) +} + +fn validate_github_repo_name(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err("Repository name is required.".to_string()); + } + if trimmed.chars().any(|ch| ch.is_whitespace()) { + return Err("Repository name cannot contain spaces.".to_string()); + } + if trimmed.starts_with('/') || trimmed.ends_with('/') { + return Err("Repository name cannot start or end with '/'.".to_string()); + } + if trimmed.contains("//") { + return Err("Repository name cannot contain '//'.".to_string()); + } + Ok(trimmed.to_string()) +} + +fn github_repo_exists_message(lower: &str) -> bool { + lower.contains("already exists") + || lower.contains("name already exists") + || lower.contains("has already been taken") + || lower.contains("repository with this name already exists") +} + +fn normalize_repo_full_name(value: &str) -> String { + value + .trim() + .trim_start_matches("https://github.com/") + .trim_start_matches("http://github.com/") + .trim_start_matches("git@github.com:") + .trim_end_matches(".git") + .trim_end_matches('/') + .to_string() +} + +fn git_remote_url(repo_root: &Path, remote_name: &str) -> Option { + let repo = Repository::open(repo_root).ok()?; + let remote = repo.find_remote(remote_name).ok()?; + remote.url().map(|url| url.to_string()) +} + +async fn init_git_repo_inner( + workspaces: &Mutex>, + workspace_id: String, + branch: String, + force: bool, +) -> Result { + const INITIAL_COMMIT_MESSAGE: &str = "Initial commit"; + + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + let branch = validate_branch_name(&branch)?; + + if Repository::open(&repo_root).is_ok() { + return Ok(json!({ "status": "already_initialized" })); + } + + if !force { + let entry_count = count_effective_dir_entries(&repo_root)?; + if entry_count > 0 { + return Ok(json!({ "status": "needs_confirmation", "entryCount": entry_count })); + } + } + + let init_with_branch = run_git_command( + &repo_root, + &["init", "--initial-branch", branch.as_str()], + ) + .await; + + if let Err(error) = init_with_branch { + let lower = error.to_lowercase(); + let unsupported = lower.contains("initial-branch") + && (lower.contains("unknown option") + || lower.contains("unrecognized option") + || lower.contains("unknown switch") + || lower.contains("usage:")); + if !unsupported { + return Err(error); + } + + run_git_command(&repo_root, &["init"]).await?; + let head_ref = format!("refs/heads/{branch}"); + run_git_command(&repo_root, &["symbolic-ref", "HEAD", head_ref.as_str()]).await?; + } + + // Make the repository usable immediately (avoid "unborn HEAD" edge cases). + // This stages all non-ignored files and creates an initial commit. If the + // commit fails (e.g. missing user.name/user.email), we still report + // initialization success but include the commit error so the UI can surface it. + let commit_error = match run_git_command(&repo_root, &["add", "-A"]).await { + Ok(()) => match run_git_command( + &repo_root, + &["commit", "--allow-empty", "-m", INITIAL_COMMIT_MESSAGE], + ) + .await + { + Ok(()) => None, + Err(err) => Some(err), + }, + Err(err) => Some(err), + }; + + if let Some(commit_error) = commit_error { + return Ok(json!({ "status": "initialized", "commitError": commit_error })); + } + + Ok(json!({ "status": "initialized" })) +} + +async fn create_github_repo_inner( + workspaces: &Mutex>, + workspace_id: String, + repo: String, + visibility: String, + branch: Option, +) -> Result { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + let repo = normalize_repo_full_name(&validate_github_repo_name(&repo)?); + + let visibility_flag = match visibility.trim() { + "private" => "--private", + "public" => "--public", + other => return Err(format!("Invalid repo visibility: {other}")), + }; + + // Ensure local repo exists before creating a remote from it. + let local_repo = Repository::open(&repo_root) + .map_err(|_| "Git is not initialized in this folder yet.".to_string())?; + let origin_url_before = local_repo + .find_remote("origin") + .ok() + .and_then(|remote| remote.url().map(|url| url.to_string())); + + let full_name = if repo.contains('/') { + repo + } else { + let owner = gh_stdout_trim(&repo_root, &["api", "user", "--jq", ".login"]).await?; + if owner.trim().is_empty() { + return Err("Failed to determine GitHub username.".to_string()); + } + format!("{owner}/{repo}") + }; + + if origin_url_before.is_none() { + let create_result = run_gh_command( + &repo_root, + &[ + "repo", + "create", + &full_name, + visibility_flag, + "--source=.", + "--remote=origin", + ], + ) + .await; + + if let Err(error) = create_result { + let lower = error.to_lowercase(); + if !github_repo_exists_message(&lower) { + return Err(error); + } + } + } + + // Ensure origin exists; gh should create it, but retries / edge cases can leave it missing. + if git_remote_url(&repo_root, "origin").is_none() { + let protocol = gh_git_protocol(&repo_root).await; + let jq_field = if protocol.trim() == "ssh" { + ".sshUrl" + } else { + ".httpsUrl" + }; + let remote_url = gh_stdout_trim( + &repo_root, + &[ + "repo", + "view", + &full_name, + "--json", + "sshUrl,httpsUrl", + "--jq", + jq_field, + ], + ) + .await?; + if remote_url.trim().is_empty() { + return Err("Failed to resolve GitHub remote URL.".to_string()); + } + run_git_command(&repo_root, &["remote", "add", "origin", remote_url.trim()]).await?; + } + + let remote_url = git_remote_url(&repo_root, "origin"); + + let push_result = run_git_command(&repo_root, &["push", "-u", "origin", "HEAD"]).await; + + let default_branch = if let Some(branch) = branch { + Some(validate_branch_name(&branch)?) + } else { + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; + let head = repo.head().ok(); + let name = head + .as_ref() + .filter(|head| head.is_branch()) + .and_then(|head| head.shorthand()) + .map(str::to_string); + name.and_then(|name| validate_branch_name(&name).ok()) + }; + + let default_branch_result = if let Some(branch) = default_branch.as_deref() { + run_gh_command( + &repo_root, + &[ + "api", + "-X", + "PATCH", + &format!("/repos/{full_name}"), + "-f", + &format!("default_branch={branch}"), + ], + ) + .await + .map(|_| ()) + } else { + Ok(()) + }; + + let push_error = push_result.err(); + let default_branch_error = default_branch_result.err(); + + if push_error.is_some() || default_branch_error.is_some() { + return Ok(json!({ + "status": "partial", + "repo": full_name, + "remoteUrl": remote_url, + "pushError": push_error, + "defaultBranchError": default_branch_error, + })); + } + + Ok(json!({ + "status": "ok", + "repo": full_name, + "remoteUrl": remote_url, + })) +} + async fn stage_git_file_inner( workspaces: &Mutex>, workspace_id: String, @@ -1587,6 +1921,25 @@ pub(crate) async fn get_git_status_core( get_git_status_inner(workspaces, workspace_id).await } +pub(crate) async fn init_git_repo_core( + workspaces: &Mutex>, + workspace_id: String, + branch: String, + force: bool, +) -> Result { + init_git_repo_inner(workspaces, workspace_id, branch, force).await +} + +pub(crate) async fn create_github_repo_core( + workspaces: &Mutex>, + workspace_id: String, + repo: String, + visibility: String, + branch: Option, +) -> Result { + create_github_repo_inner(workspaces, workspace_id, repo, visibility, branch).await +} + pub(crate) async fn list_git_roots_core( workspaces: &Mutex>, workspace_id: String, diff --git a/src/App.tsx b/src/App.tsx index e4ea3d6a7..31e98b659 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,7 @@ import "./styles/tabbar.css"; import "./styles/worktree-modal.css"; import "./styles/clone-modal.css"; import "./styles/branch-switcher-modal.css"; +import "./styles/git-init-modal.css"; import "./styles/settings.css"; import "./styles/compact-base.css"; import "./styles/compact-phone.css"; @@ -50,6 +51,8 @@ import { useGitRepoScan } from "./features/git/hooks/useGitRepoScan"; import { usePullRequestComposer } from "./features/git/hooks/usePullRequestComposer"; import { useGitActions } from "./features/git/hooks/useGitActions"; import { useAutoExitEmptyDiff } from "./features/git/hooks/useAutoExitEmptyDiff"; +import { isMissingRepo } from "./features/git/utils/repoErrors"; +import { useInitGitRepoPrompt } from "./features/git/hooks/useInitGitRepoPrompt"; import { useModels } from "./features/models/hooks/useModels"; import { useCollaborationModes } from "./features/collaboration/hooks/useCollaborationModes"; import { useCollaborationModeSelection } from "./features/collaboration/hooks/useCollaborationModeSelection"; @@ -439,7 +442,7 @@ function MainApp() { useEffect(() => { resetGitHubPanelState(); }, [activeWorkspaceId, resetGitHubPanelState]); - const { remote: gitRemoteUrl } = useGitRemote(activeWorkspace); + const { remote: gitRemoteUrl, refresh: refreshGitRemote } = useGitRemote(activeWorkspace); const { repos: gitRootCandidates, isLoading: gitRootScanLoading, @@ -662,6 +665,10 @@ function MainApp() { }, []); const { applyWorktreeChanges: handleApplyWorktreeChanges, + createGitHubRepo: handleCreateGitHubRepo, + createGitHubRepoLoading, + initGitRepo: handleInitGitRepo, + initGitRepoLoading, revertAllGitChanges: handleRevertAllGitChanges, revertGitFile: handleRevertGitFile, stageGitAll: handleStageGitAll, @@ -674,9 +681,27 @@ function MainApp() { activeWorkspace, onRefreshGitStatus: refreshGitStatus, onRefreshGitDiffs: refreshGitDiffs, + onClearGitRootCandidates: clearGitRootCandidates, onError: alertError, }); + const { + initGitRepoPrompt, + openInitGitRepoPrompt, + handleInitGitRepoPromptBranchChange, + handleInitGitRepoPromptCreateRemoteChange, + handleInitGitRepoPromptRepoNameChange, + handleInitGitRepoPromptPrivateChange, + handleInitGitRepoPromptCancel, + handleInitGitRepoPromptConfirm, + } = useInitGitRepoPrompt({ + activeWorkspace, + initGitRepo: handleInitGitRepo, + createGitHubRepo: handleCreateGitHubRepo, + refreshGitRemote, + isBusy: initGitRepoLoading || createGitHubRepoLoading, + }); + const resolvedModel = selectedModel?.model ?? null; const resolvedEffort = reasoningSupported ? selectedEffort : null; const { activeGitRoot, handleSetGitRoot, handlePickGitRoot } = useGitRootSelection({ @@ -2252,6 +2277,8 @@ function MainApp() { void handleSetGitRoot(null); }, onPickGitRoot: handlePickGitRoot, + onInitGitRepo: openInitGitRepoPrompt, + initGitRepoLoading, onStageGitAll: handleStageGitAll, onStageGitFile: handleStageGitFile, onUnstageGitFile: handleUnstageGitFile, @@ -2415,9 +2442,18 @@ function MainApp() { onWorkspaceDrop: handleWorkspaceDrop, }); + const gitRootOverride = activeWorkspace?.settings.gitRoot; + const hasGitRootOverride = + typeof gitRootOverride === "string" && gitRootOverride.trim().length > 0; + const showGitInitBanner = + Boolean(activeWorkspace) && !hasGitRootOverride && isMissingRepo(gitStatus.error); + const workspaceHomeNode = activeWorkspace ? ( default: module.BranchSwitcherPrompt, })), ); +const InitGitRepoPrompt = lazy(() => + import("../../git/components/InitGitRepoPrompt").then((module) => ({ + default: module.InitGitRepoPrompt, + })), +); type RenamePromptState = ReturnType["renamePrompt"]; @@ -40,6 +45,21 @@ type AppModalsProps = { onRenamePromptChange: (value: string) => void; onRenamePromptCancel: () => void; onRenamePromptConfirm: () => void; + initGitRepoPrompt: { + workspaceName: string; + branch: string; + createRemote: boolean; + repoName: string; + isPrivate: boolean; + error: string | null; + } | null; + initGitRepoPromptBusy: boolean; + onInitGitRepoPromptBranchChange: (value: string) => void; + onInitGitRepoPromptCreateRemoteChange: (value: boolean) => void; + onInitGitRepoPromptRepoNameChange: (value: string) => void; + onInitGitRepoPromptPrivateChange: (value: boolean) => void; + onInitGitRepoPromptCancel: () => void; + onInitGitRepoPromptConfirm: () => void; worktreePrompt: WorktreePromptState; onWorktreePromptNameChange: (value: string) => void; onWorktreePromptChange: (value: string) => void; @@ -73,6 +93,14 @@ export const AppModals = memo(function AppModals({ onRenamePromptChange, onRenamePromptCancel, onRenamePromptConfirm, + initGitRepoPrompt, + initGitRepoPromptBusy, + onInitGitRepoPromptBranchChange, + onInitGitRepoPromptCreateRemoteChange, + onInitGitRepoPromptRepoNameChange, + onInitGitRepoPromptPrivateChange, + onInitGitRepoPromptCancel, + onInitGitRepoPromptConfirm, worktreePrompt, onWorktreePromptNameChange, onWorktreePromptChange, @@ -117,6 +145,25 @@ export const AppModals = memo(function AppModals({ /> )} + {initGitRepoPrompt && ( + + + + )} {worktreePrompt && ( { + it("shows an initialize git button when the repo is missing", () => { + const onInitGitRepo = vi.fn(); + const { container } = render( + , + ); + + const initButton = within(container).getByRole("button", { name: "Initialize Git" }); + fireEvent.click(initButton); + expect(onInitGitRepo).toHaveBeenCalledTimes(1); + }); + + it("does not show initialize git when the git root path is invalid", () => { + const { container } = render( + , + ); + + expect(within(container).queryByRole("button", { name: "Initialize Git" })).toBeNull(); + }); + it("enables commit when message exists and only unstaged changes", () => { const onCommit = vi.fn(); render( diff --git a/src/features/git/components/GitDiffPanel.tsx b/src/features/git/components/GitDiffPanel.tsx index b6817d242..3d5107f2f 100644 --- a/src/features/git/components/GitDiffPanel.tsx +++ b/src/features/git/components/GitDiffPanel.tsx @@ -95,6 +95,8 @@ type GitDiffPanelProps = { onSelectGitRoot?: (path: string) => void; onClearGitRoot?: () => void; onPickGitRoot?: () => void | Promise; + onInitGitRepo?: () => void | Promise; + initGitRepoLoading?: boolean; selectedPath?: string | null; onSelectFile?: (path: string) => void; stagedFiles: { @@ -202,6 +204,8 @@ export function GitDiffPanel({ onSelectGitRoot, onClearGitRoot, onPickGitRoot, + onInitGitRepo, + initGitRepoLoading = false, commitMessage = "", commitMessageLoading = false, commitMessageError = null, @@ -699,6 +703,8 @@ export function GitDiffPanel({ gitRootScanDepth={gitRootScanDepth} onGitRootScanDepthChange={onGitRootScanDepthChange} onPickGitRoot={onPickGitRoot} + onInitGitRepo={onInitGitRepo} + initGitRepoLoading={initGitRepoLoading} hasGitRoot={hasGitRoot} onClearGitRoot={onClearGitRoot} gitRootScanError={gitRootScanError} diff --git a/src/features/git/components/GitDiffPanel.utils.ts b/src/features/git/components/GitDiffPanel.utils.ts index 369673d95..9a11f9410 100644 --- a/src/features/git/components/GitDiffPanel.utils.ts +++ b/src/features/git/components/GitDiffPanel.utils.ts @@ -1,4 +1,5 @@ import { isAbsolutePath as isAbsolutePathForPlatform } from "../../../utils/platformPaths"; +export { isGitRootNotFound, isMissingRepo } from "../utils/repoErrors"; export const DEPTH_OPTIONS = [1, 2, 3, 4, 5, 6]; @@ -114,20 +115,6 @@ export function getStatusClass(status: string) { } } -export function isMissingRepo(error: string | null | undefined) { - if (!error) { - return false; - } - const normalized = error.toLowerCase(); - return ( - normalized.includes("could not find repository") || - normalized.includes("not a git repository") || - (normalized.includes("repository") && normalized.includes("notfound")) || - normalized.includes("repository not found") || - normalized.includes("git root not found") - ); -} - export function hasPushSyncConflict(pushError: string | null | undefined) { if (!pushError) { return false; diff --git a/src/features/git/components/GitDiffPanelModeContent.tsx b/src/features/git/components/GitDiffPanelModeContent.tsx index 64b220718..534e512c7 100644 --- a/src/features/git/components/GitDiffPanelModeContent.tsx +++ b/src/features/git/components/GitDiffPanelModeContent.tsx @@ -13,7 +13,12 @@ import { type DiffFile, GitLogEntryRow, } from "./GitDiffPanelShared"; -import { DEPTH_OPTIONS, normalizeRootPath } from "./GitDiffPanel.utils"; +import { + DEPTH_OPTIONS, + isGitRootNotFound, + isMissingRepo, + normalizeRootPath, +} from "./GitDiffPanel.utils"; type GitMode = "diff" | "log" | "issues" | "prs"; @@ -169,6 +174,8 @@ type GitDiffModeContentProps = { gitRootScanDepth: number; onGitRootScanDepthChange?: (depth: number) => void; onPickGitRoot?: () => void | Promise; + onInitGitRepo?: () => void | Promise; + initGitRepoLoading: boolean; hasGitRoot: boolean; onClearGitRoot?: () => void; gitRootScanError: string | null | undefined; @@ -223,6 +230,8 @@ export function GitDiffModeContent({ gitRootScanDepth, onGitRootScanDepthChange, onPickGitRoot, + onInitGitRepo, + initGitRepoLoading, hasGitRoot, onClearGitRoot, gitRootScanError, @@ -261,18 +270,38 @@ export function GitDiffModeContent({ onDiffListClick, }: GitDiffModeContentProps) { const normalizedGitRoot = normalizeRootPath(gitRoot); + const missingRepo = isMissingRepo(error); + const gitRootNotFound = isGitRootNotFound(error); + const showInitGitRepo = Boolean(onInitGitRepo) && missingRepo && !gitRootNotFound; + const gitRootTitle = gitRootNotFound + ? "Git root folder not found." + : missingRepo + ? "This workspace isn't a Git repository yet." + : "Choose a repo for this workspace."; return (
{showGitRootPanel && (
-
Choose a repo for this workspace.
+
{gitRootTitle}
+ {showInitGitRepo && ( + + )} @@ -287,7 +316,7 @@ export function GitDiffModeContent({ onGitRootScanDepthChange?.(value); } }} - disabled={gitRootScanLoading} + disabled={gitRootScanLoading || initGitRepoLoading} > {DEPTH_OPTIONS.map((depth) => (
+ {showGitInitBanner && ( + + )} +
{ + it("calls onInitGitRepo when clicked", () => { + const onInitGitRepo = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Initialize Git" })); + expect(onInitGitRepo).toHaveBeenCalledTimes(1); + }); + + it("disables the button when loading", () => { + render(); + + const button = screen.getByRole("button", { name: "Initializing..." }); + expect((button as HTMLButtonElement).disabled).toBe(true); + }); +}); + diff --git a/src/features/workspaces/components/WorkspaceHomeGitInitBanner.tsx b/src/features/workspaces/components/WorkspaceHomeGitInitBanner.tsx new file mode 100644 index 000000000..fa2c77716 --- /dev/null +++ b/src/features/workspaces/components/WorkspaceHomeGitInitBanner.tsx @@ -0,0 +1,28 @@ +type WorkspaceHomeGitInitBannerProps = { + isLoading: boolean; + onInitGitRepo: () => void | Promise; +}; + +export function WorkspaceHomeGitInitBanner({ + isLoading, + onInitGitRepo, +}: WorkspaceHomeGitInitBannerProps) { + return ( +
+
+ Git is not initialized for this project. +
+
+ +
+
+ ); +} + diff --git a/src/services/tauri.test.ts b/src/services/tauri.test.ts index b13acf6f4..a75e58d22 100644 --- a/src/services/tauri.test.ts +++ b/src/services/tauri.test.ts @@ -5,6 +5,7 @@ import * as notification from "@tauri-apps/plugin-notification"; import { addWorkspace, compactThread, + createGitHubRepo, fetchGit, forkThread, getAppsList, @@ -121,6 +122,20 @@ describe("tauri invoke wrappers", () => { }); }); + it("maps args for createGitHubRepo", async () => { + const invokeMock = vi.mocked(invoke); + invokeMock.mockResolvedValueOnce({ status: "ok", repo: "me/repo" }); + + await createGitHubRepo("ws-77", "me/repo", "private", "main"); + + expect(invokeMock).toHaveBeenCalledWith("create_github_repo", { + workspaceId: "ws-77", + repo: "me/repo", + visibility: "private", + branch: "main", + }); + }); + it("maps workspace_id to workspaceId for GitHub issues", async () => { const invokeMock = vi.mocked(invoke); invokeMock.mockResolvedValueOnce({ total: 0, issues: [] }); diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 0efe0c3b7..bf5ae8135 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -387,6 +387,43 @@ export async function getGitStatus(workspace_id: string): Promise<{ return invoke("get_git_status", { workspaceId: workspace_id }); } +export type InitGitRepoResponse = + | { status: "initialized"; commitError?: string } + | { status: "already_initialized" } + | { status: "needs_confirmation"; entryCount: number }; + +export async function initGitRepo( + workspaceId: string, + branch: string, + force = false, +): Promise { + return invoke("init_git_repo", { workspaceId, branch, force }); +} + +export type CreateGitHubRepoResponse = + | { status: "ok"; repo: string; remoteUrl?: string | null } + | { + status: "partial"; + repo: string; + remoteUrl?: string | null; + pushError?: string | null; + defaultBranchError?: string | null; + }; + +export async function createGitHubRepo( + workspaceId: string, + repo: string, + visibility: "private" | "public", + branch?: string | null, +): Promise { + return invoke("create_github_repo", { + workspaceId, + repo, + visibility, + branch, + }); +} + export async function listGitRoots( workspace_id: string, depth: number, diff --git a/src/styles/ds-modal.css b/src/styles/ds-modal.css index d18b7bd12..98ef07dfd 100644 --- a/src/styles/ds-modal.css +++ b/src/styles/ds-modal.css @@ -1,7 +1,7 @@ .ds-modal { position: fixed; inset: 0; - z-index: 40; + z-index: var(--ds-layer-modal, 40); } .ds-modal-backdrop { diff --git a/src/styles/ds-tokens.css b/src/styles/ds-tokens.css index 76a0a9bd4..b7965aa6f 100644 --- a/src/styles/ds-tokens.css +++ b/src/styles/ds-tokens.css @@ -37,4 +37,8 @@ --ds-diff-lib-bg-dark: rgba(10, 12, 16, 0.35); --ds-diff-lib-bg-system-light: rgba(255, 255, 255, 0.35); --ds-diff-lib-bg-system-dark: rgba(10, 12, 16, 0.35); + + /* Global layer scale (keep numeric so it works with z-index + calc()). */ + --ds-layer-modal: 10000; + --ds-layer-toast: 11000; } diff --git a/src/styles/error-toasts.css b/src/styles/error-toasts.css index d1177e825..b3bf75a7b 100644 --- a/src/styles/error-toasts.css +++ b/src/styles/error-toasts.css @@ -3,7 +3,7 @@ top: 16px; left: 50%; transform: translateX(-50%); - z-index: 60; + z-index: var(--ds-layer-toast, 60); gap: 8px; pointer-events: none; } diff --git a/src/styles/git-init-modal.css b/src/styles/git-init-modal.css new file mode 100644 index 000000000..fd1d9939c --- /dev/null +++ b/src/styles/git-init-modal.css @@ -0,0 +1,57 @@ +.git-init-modal .ds-modal-card { + width: min(520px, calc(100vw - 48px)); + border-radius: 16px; + padding: 18px 20px; + display: flex; + flex-direction: column; + gap: 12px; + /* Make this modal fully opaque (no glass/transparency). */ + background: var(--surface-sidebar-opaque); +} + +.git-init-modal .ds-modal-backdrop { + /* Fully opaque backdrop; no blur/transparency so nothing bleeds through. */ + background: color-mix(in srgb, var(--surface-sidebar-opaque) 18%, black); + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +.git-init-modal-checkbox-row { + display: flex; + align-items: flex-start; + gap: 10px; + font-size: 12px; + color: var(--ds-text-subtle); +} + +.git-init-modal-checkbox-row code { + font-family: var(--code-font-family, Menlo, Monaco, "Courier New", monospace); + color: var(--ds-text-strong); +} + +.git-init-modal-checkbox-row--nested { + margin-top: 2px; + padding-left: 22px; +} + +.git-init-modal-checkbox { + margin-top: 2px; +} + +.git-init-modal-remote { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 12px; + border-radius: 14px; + border: 1px solid var(--ds-border-subtle); + background: var(--surface-sidebar-opaque); +} + +.git-init-modal-actions { + margin-top: 8px; +} + +.git-init-modal-button { + white-space: nowrap; +} diff --git a/src/styles/mobile-setup-wizard.css b/src/styles/mobile-setup-wizard.css index 162477f91..9d7d9719c 100644 --- a/src/styles/mobile-setup-wizard.css +++ b/src/styles/mobile-setup-wizard.css @@ -1,5 +1,5 @@ .mobile-setup-wizard-overlay { - z-index: 120; + z-index: calc(var(--ds-layer-modal, 40) + 80); } .mobile-setup-wizard-overlay .ds-modal-backdrop { diff --git a/src/styles/workspace-home.css b/src/styles/workspace-home.css index 64242ea17..2903b24f0 100644 --- a/src/styles/workspace-home.css +++ b/src/styles/workspace-home.css @@ -42,6 +42,33 @@ word-break: break-all; } +.workspace-home-git-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px; + border-radius: 12px; + border: 1px solid var(--border-subtle); + background: var(--surface-card); +} + +.workspace-home-git-banner-title { + font-size: 12px; + color: var(--text-strong); +} + +.workspace-home-git-banner-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.workspace-home-git-banner-actions button { + padding: 8px 12px; + font-size: 12px; +} + .workspace-home-composer { display: flex; flex-direction: column; From 6d13321f27c1f6938fcee2ee40bb3aee4a97862c Mon Sep 17 00:00:00 2001 From: Samigos Date: Wed, 11 Feb 2026 14:05:15 +0200 Subject: [PATCH 2/8] Handle git init cancel without failure error --- src/features/git/hooks/useGitActions.ts | 21 +++--- .../git/hooks/useInitGitRepoPrompt.test.tsx | 75 +++++++++++++++++++ .../git/hooks/useInitGitRepoPrompt.ts | 13 +++- 3 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 src/features/git/hooks/useInitGitRepoPrompt.test.tsx diff --git a/src/features/git/hooks/useGitActions.ts b/src/features/git/hooks/useGitActions.ts index c3c169915..63863dcd7 100644 --- a/src/features/git/hooks/useGitActions.ts +++ b/src/features/git/hooks/useGitActions.ts @@ -20,6 +20,8 @@ type UseGitActionsOptions = { onError?: (error: unknown) => void; }; +export type InitGitRepoOutcome = "initialized" | "cancelled" | "failed"; + export function useGitActions({ activeWorkspace, onRefreshGitStatus, @@ -188,19 +190,19 @@ export function useGitActions({ } }, [isWorktree, workspaceId]); - const initGitRepo = useCallback(async (branch: string) => { + const initGitRepo = useCallback(async (branch: string): Promise => { if (!workspaceId) { - return false; + return "failed"; } const actionWorkspaceId = workspaceId; setInitGitRepoLoading(true); let shouldRefresh = false; - let completed = false; + let outcome: InitGitRepoOutcome = "failed"; let commitError: string | null = null; try { const response = await initGitRepoService(actionWorkspaceId, branch, false); if (workspaceIdRef.current !== actionWorkspaceId) { - return false; + return "cancelled"; } if (response.status === "needs_confirmation") { @@ -216,11 +218,11 @@ export function useGitActions({ }, ); if (!confirmed) { - return false; + return "cancelled"; } if (workspaceIdRef.current !== actionWorkspaceId) { - return false; + return "cancelled"; } const forced = await initGitRepoService(actionWorkspaceId, branch, true); @@ -228,13 +230,13 @@ export function useGitActions({ if (forced.status === "initialized") { commitError = forced.commitError ?? null; } - completed = shouldRefresh; + outcome = shouldRefresh ? "initialized" : "failed"; } else { shouldRefresh = response.status === "initialized" || response.status === "already_initialized"; if (response.status === "initialized") { commitError = response.commitError ?? null; } - completed = shouldRefresh; + outcome = shouldRefresh ? "initialized" : "failed"; } if (commitError) { @@ -246,6 +248,7 @@ export function useGitActions({ } } catch (error) { onError?.(error); + outcome = "failed"; } finally { if (workspaceIdRef.current === actionWorkspaceId) { setInitGitRepoLoading(false); @@ -255,7 +258,7 @@ export function useGitActions({ } } } - return completed; + return outcome; }, [onClearGitRootCandidates, onError, refreshGitData, workspaceId]); const createGitHubRepo = useCallback( diff --git a/src/features/git/hooks/useInitGitRepoPrompt.test.tsx b/src/features/git/hooks/useInitGitRepoPrompt.test.tsx new file mode 100644 index 000000000..266d20c7a --- /dev/null +++ b/src/features/git/hooks/useInitGitRepoPrompt.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceInfo } from "../../../types"; +import { useInitGitRepoPrompt } from "./useInitGitRepoPrompt"; + +const workspace: WorkspaceInfo = { + id: "ws-1", + name: "Repo", + path: "/tmp/repo", + connected: true, + settings: { + sidebarCollapsed: false, + }, +}; + +describe("useInitGitRepoPrompt", () => { + it("does not set generic error when init confirmation is canceled", async () => { + const initGitRepo = vi.fn().mockResolvedValue("cancelled"); + const createGitHubRepo = vi.fn().mockResolvedValue({ ok: true }); + const refreshGitRemote = vi.fn(); + + const { result } = renderHook(() => + useInitGitRepoPrompt({ + activeWorkspace: workspace, + initGitRepo, + createGitHubRepo, + refreshGitRemote, + isBusy: false, + }), + ); + + act(() => { + result.current.openInitGitRepoPrompt(); + }); + + await act(async () => { + await result.current.handleInitGitRepoPromptConfirm(); + }); + + expect(initGitRepo).toHaveBeenCalledWith("main"); + expect(createGitHubRepo).not.toHaveBeenCalled(); + expect(result.current.initGitRepoPrompt).not.toBeNull(); + expect(result.current.initGitRepoPrompt?.error).toBeNull(); + }); + + it("sets generic error when init actually fails", async () => { + const initGitRepo = vi.fn().mockResolvedValue("failed"); + const createGitHubRepo = vi.fn().mockResolvedValue({ ok: true }); + const refreshGitRemote = vi.fn(); + + const { result } = renderHook(() => + useInitGitRepoPrompt({ + activeWorkspace: workspace, + initGitRepo, + createGitHubRepo, + refreshGitRemote, + isBusy: false, + }), + ); + + act(() => { + result.current.openInitGitRepoPrompt(); + }); + + await act(async () => { + await result.current.handleInitGitRepoPromptConfirm(); + }); + + expect(result.current.initGitRepoPrompt?.error).toBe( + "Failed to initialize Git repository.", + ); + }); +}); + diff --git a/src/features/git/hooks/useInitGitRepoPrompt.ts b/src/features/git/hooks/useInitGitRepoPrompt.ts index bf327a5b8..a6f6fea7a 100644 --- a/src/features/git/hooks/useInitGitRepoPrompt.ts +++ b/src/features/git/hooks/useInitGitRepoPrompt.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import type { WorkspaceInfo } from "../../../types"; import { validateBranchName } from "../utils/branchValidation"; +import type { InitGitRepoOutcome } from "./useGitActions"; type InitGitRepoPromptState = { workspaceId: string; @@ -20,7 +21,7 @@ export function useInitGitRepoPrompt({ isBusy, }: { activeWorkspace: WorkspaceInfo | null; - initGitRepo: (branch: string) => Promise; + initGitRepo: (branch: string) => Promise; createGitHubRepo: ( repo: string, visibility: "private" | "public", @@ -163,8 +164,14 @@ export function useInitGitRepoPrompt({ return; } - const ok = await initGitRepo(trimmedBranch); - if (!ok) { + setInitGitRepoPrompt((prev) => (prev ? { ...prev, error: null } : prev)); + + const initOutcome = await initGitRepo(trimmedBranch); + if (initOutcome === "cancelled") { + return; + } + + if (initOutcome !== "initialized") { setInitGitRepoPrompt((prev) => prev ? { ...prev, error: prev.error ?? "Failed to initialize Git repository." } : prev, ); From a770e1852fa729ea3b56b36437f202c648a22c5e Mon Sep 17 00:00:00 2001 From: Samigos Date: Wed, 11 Feb 2026 14:59:47 +0200 Subject: [PATCH 3/8] Place git init button above git root actions --- src/features/git/components/GitDiffPanelModeContent.tsx | 8 +++++--- src/styles/diff.css | 4 ++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/features/git/components/GitDiffPanelModeContent.tsx b/src/features/git/components/GitDiffPanelModeContent.tsx index 534e512c7..c7b18d66d 100644 --- a/src/features/git/components/GitDiffPanelModeContent.tsx +++ b/src/features/git/components/GitDiffPanelModeContent.tsx @@ -284,8 +284,8 @@ export function GitDiffModeContent({ {showGitRootPanel && (
{gitRootTitle}
-
- {showInitGitRepo && ( + {showInitGitRepo && ( +
- )} +
+ )} +
+ )} @@ -287,7 +316,7 @@ export function GitDiffModeContent({ onGitRootScanDepthChange?.(value); } }} - disabled={gitRootScanLoading} + disabled={gitRootScanLoading || initGitRepoLoading} > {DEPTH_OPTIONS.map((depth) => (
+ {showGitInitBanner && ( + + )} +
{ + it("calls onInitGitRepo when clicked", () => { + const onInitGitRepo = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Initialize Git" })); + expect(onInitGitRepo).toHaveBeenCalledTimes(1); + }); + + it("disables the button when loading", () => { + render(); + + const button = screen.getByRole("button", { name: "Initializing..." }); + expect((button as HTMLButtonElement).disabled).toBe(true); + }); +}); + diff --git a/src/features/workspaces/components/WorkspaceHomeGitInitBanner.tsx b/src/features/workspaces/components/WorkspaceHomeGitInitBanner.tsx new file mode 100644 index 000000000..fa2c77716 --- /dev/null +++ b/src/features/workspaces/components/WorkspaceHomeGitInitBanner.tsx @@ -0,0 +1,28 @@ +type WorkspaceHomeGitInitBannerProps = { + isLoading: boolean; + onInitGitRepo: () => void | Promise; +}; + +export function WorkspaceHomeGitInitBanner({ + isLoading, + onInitGitRepo, +}: WorkspaceHomeGitInitBannerProps) { + return ( +
+
+ Git is not initialized for this project. +
+
+ +
+
+ ); +} + diff --git a/src/services/tauri.test.ts b/src/services/tauri.test.ts index b13acf6f4..a75e58d22 100644 --- a/src/services/tauri.test.ts +++ b/src/services/tauri.test.ts @@ -5,6 +5,7 @@ import * as notification from "@tauri-apps/plugin-notification"; import { addWorkspace, compactThread, + createGitHubRepo, fetchGit, forkThread, getAppsList, @@ -121,6 +122,20 @@ describe("tauri invoke wrappers", () => { }); }); + it("maps args for createGitHubRepo", async () => { + const invokeMock = vi.mocked(invoke); + invokeMock.mockResolvedValueOnce({ status: "ok", repo: "me/repo" }); + + await createGitHubRepo("ws-77", "me/repo", "private", "main"); + + expect(invokeMock).toHaveBeenCalledWith("create_github_repo", { + workspaceId: "ws-77", + repo: "me/repo", + visibility: "private", + branch: "main", + }); + }); + it("maps workspace_id to workspaceId for GitHub issues", async () => { const invokeMock = vi.mocked(invoke); invokeMock.mockResolvedValueOnce({ total: 0, issues: [] }); diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 0efe0c3b7..bf5ae8135 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -387,6 +387,43 @@ export async function getGitStatus(workspace_id: string): Promise<{ return invoke("get_git_status", { workspaceId: workspace_id }); } +export type InitGitRepoResponse = + | { status: "initialized"; commitError?: string } + | { status: "already_initialized" } + | { status: "needs_confirmation"; entryCount: number }; + +export async function initGitRepo( + workspaceId: string, + branch: string, + force = false, +): Promise { + return invoke("init_git_repo", { workspaceId, branch, force }); +} + +export type CreateGitHubRepoResponse = + | { status: "ok"; repo: string; remoteUrl?: string | null } + | { + status: "partial"; + repo: string; + remoteUrl?: string | null; + pushError?: string | null; + defaultBranchError?: string | null; + }; + +export async function createGitHubRepo( + workspaceId: string, + repo: string, + visibility: "private" | "public", + branch?: string | null, +): Promise { + return invoke("create_github_repo", { + workspaceId, + repo, + visibility, + branch, + }); +} + export async function listGitRoots( workspace_id: string, depth: number, diff --git a/src/styles/ds-modal.css b/src/styles/ds-modal.css index d18b7bd12..98ef07dfd 100644 --- a/src/styles/ds-modal.css +++ b/src/styles/ds-modal.css @@ -1,7 +1,7 @@ .ds-modal { position: fixed; inset: 0; - z-index: 40; + z-index: var(--ds-layer-modal, 40); } .ds-modal-backdrop { diff --git a/src/styles/ds-tokens.css b/src/styles/ds-tokens.css index 76a0a9bd4..b7965aa6f 100644 --- a/src/styles/ds-tokens.css +++ b/src/styles/ds-tokens.css @@ -37,4 +37,8 @@ --ds-diff-lib-bg-dark: rgba(10, 12, 16, 0.35); --ds-diff-lib-bg-system-light: rgba(255, 255, 255, 0.35); --ds-diff-lib-bg-system-dark: rgba(10, 12, 16, 0.35); + + /* Global layer scale (keep numeric so it works with z-index + calc()). */ + --ds-layer-modal: 10000; + --ds-layer-toast: 11000; } diff --git a/src/styles/error-toasts.css b/src/styles/error-toasts.css index d1177e825..b3bf75a7b 100644 --- a/src/styles/error-toasts.css +++ b/src/styles/error-toasts.css @@ -3,7 +3,7 @@ top: 16px; left: 50%; transform: translateX(-50%); - z-index: 60; + z-index: var(--ds-layer-toast, 60); gap: 8px; pointer-events: none; } diff --git a/src/styles/git-init-modal.css b/src/styles/git-init-modal.css new file mode 100644 index 000000000..fd1d9939c --- /dev/null +++ b/src/styles/git-init-modal.css @@ -0,0 +1,57 @@ +.git-init-modal .ds-modal-card { + width: min(520px, calc(100vw - 48px)); + border-radius: 16px; + padding: 18px 20px; + display: flex; + flex-direction: column; + gap: 12px; + /* Make this modal fully opaque (no glass/transparency). */ + background: var(--surface-sidebar-opaque); +} + +.git-init-modal .ds-modal-backdrop { + /* Fully opaque backdrop; no blur/transparency so nothing bleeds through. */ + background: color-mix(in srgb, var(--surface-sidebar-opaque) 18%, black); + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +.git-init-modal-checkbox-row { + display: flex; + align-items: flex-start; + gap: 10px; + font-size: 12px; + color: var(--ds-text-subtle); +} + +.git-init-modal-checkbox-row code { + font-family: var(--code-font-family, Menlo, Monaco, "Courier New", monospace); + color: var(--ds-text-strong); +} + +.git-init-modal-checkbox-row--nested { + margin-top: 2px; + padding-left: 22px; +} + +.git-init-modal-checkbox { + margin-top: 2px; +} + +.git-init-modal-remote { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 12px; + border-radius: 14px; + border: 1px solid var(--ds-border-subtle); + background: var(--surface-sidebar-opaque); +} + +.git-init-modal-actions { + margin-top: 8px; +} + +.git-init-modal-button { + white-space: nowrap; +} diff --git a/src/styles/mobile-setup-wizard.css b/src/styles/mobile-setup-wizard.css index 162477f91..9d7d9719c 100644 --- a/src/styles/mobile-setup-wizard.css +++ b/src/styles/mobile-setup-wizard.css @@ -1,5 +1,5 @@ .mobile-setup-wizard-overlay { - z-index: 120; + z-index: calc(var(--ds-layer-modal, 40) + 80); } .mobile-setup-wizard-overlay .ds-modal-backdrop { diff --git a/src/styles/workspace-home.css b/src/styles/workspace-home.css index 64242ea17..2903b24f0 100644 --- a/src/styles/workspace-home.css +++ b/src/styles/workspace-home.css @@ -42,6 +42,33 @@ word-break: break-all; } +.workspace-home-git-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px; + border-radius: 12px; + border: 1px solid var(--border-subtle); + background: var(--surface-card); +} + +.workspace-home-git-banner-title { + font-size: 12px; + color: var(--text-strong); +} + +.workspace-home-git-banner-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.workspace-home-git-banner-actions button { + padding: 8px 12px; + font-size: 12px; +} + .workspace-home-composer { display: flex; flex-direction: column; From e4a5dd155aa810a714e5107cc45cdd8147068458 Mon Sep 17 00:00:00 2001 From: Samigos Date: Wed, 11 Feb 2026 14:05:15 +0200 Subject: [PATCH 5/8] Handle git init cancel without failure error --- src/features/git/hooks/useGitActions.ts | 21 +++--- .../git/hooks/useInitGitRepoPrompt.test.tsx | 75 +++++++++++++++++++ .../git/hooks/useInitGitRepoPrompt.ts | 13 +++- 3 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 src/features/git/hooks/useInitGitRepoPrompt.test.tsx diff --git a/src/features/git/hooks/useGitActions.ts b/src/features/git/hooks/useGitActions.ts index c3c169915..63863dcd7 100644 --- a/src/features/git/hooks/useGitActions.ts +++ b/src/features/git/hooks/useGitActions.ts @@ -20,6 +20,8 @@ type UseGitActionsOptions = { onError?: (error: unknown) => void; }; +export type InitGitRepoOutcome = "initialized" | "cancelled" | "failed"; + export function useGitActions({ activeWorkspace, onRefreshGitStatus, @@ -188,19 +190,19 @@ export function useGitActions({ } }, [isWorktree, workspaceId]); - const initGitRepo = useCallback(async (branch: string) => { + const initGitRepo = useCallback(async (branch: string): Promise => { if (!workspaceId) { - return false; + return "failed"; } const actionWorkspaceId = workspaceId; setInitGitRepoLoading(true); let shouldRefresh = false; - let completed = false; + let outcome: InitGitRepoOutcome = "failed"; let commitError: string | null = null; try { const response = await initGitRepoService(actionWorkspaceId, branch, false); if (workspaceIdRef.current !== actionWorkspaceId) { - return false; + return "cancelled"; } if (response.status === "needs_confirmation") { @@ -216,11 +218,11 @@ export function useGitActions({ }, ); if (!confirmed) { - return false; + return "cancelled"; } if (workspaceIdRef.current !== actionWorkspaceId) { - return false; + return "cancelled"; } const forced = await initGitRepoService(actionWorkspaceId, branch, true); @@ -228,13 +230,13 @@ export function useGitActions({ if (forced.status === "initialized") { commitError = forced.commitError ?? null; } - completed = shouldRefresh; + outcome = shouldRefresh ? "initialized" : "failed"; } else { shouldRefresh = response.status === "initialized" || response.status === "already_initialized"; if (response.status === "initialized") { commitError = response.commitError ?? null; } - completed = shouldRefresh; + outcome = shouldRefresh ? "initialized" : "failed"; } if (commitError) { @@ -246,6 +248,7 @@ export function useGitActions({ } } catch (error) { onError?.(error); + outcome = "failed"; } finally { if (workspaceIdRef.current === actionWorkspaceId) { setInitGitRepoLoading(false); @@ -255,7 +258,7 @@ export function useGitActions({ } } } - return completed; + return outcome; }, [onClearGitRootCandidates, onError, refreshGitData, workspaceId]); const createGitHubRepo = useCallback( diff --git a/src/features/git/hooks/useInitGitRepoPrompt.test.tsx b/src/features/git/hooks/useInitGitRepoPrompt.test.tsx new file mode 100644 index 000000000..266d20c7a --- /dev/null +++ b/src/features/git/hooks/useInitGitRepoPrompt.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceInfo } from "../../../types"; +import { useInitGitRepoPrompt } from "./useInitGitRepoPrompt"; + +const workspace: WorkspaceInfo = { + id: "ws-1", + name: "Repo", + path: "/tmp/repo", + connected: true, + settings: { + sidebarCollapsed: false, + }, +}; + +describe("useInitGitRepoPrompt", () => { + it("does not set generic error when init confirmation is canceled", async () => { + const initGitRepo = vi.fn().mockResolvedValue("cancelled"); + const createGitHubRepo = vi.fn().mockResolvedValue({ ok: true }); + const refreshGitRemote = vi.fn(); + + const { result } = renderHook(() => + useInitGitRepoPrompt({ + activeWorkspace: workspace, + initGitRepo, + createGitHubRepo, + refreshGitRemote, + isBusy: false, + }), + ); + + act(() => { + result.current.openInitGitRepoPrompt(); + }); + + await act(async () => { + await result.current.handleInitGitRepoPromptConfirm(); + }); + + expect(initGitRepo).toHaveBeenCalledWith("main"); + expect(createGitHubRepo).not.toHaveBeenCalled(); + expect(result.current.initGitRepoPrompt).not.toBeNull(); + expect(result.current.initGitRepoPrompt?.error).toBeNull(); + }); + + it("sets generic error when init actually fails", async () => { + const initGitRepo = vi.fn().mockResolvedValue("failed"); + const createGitHubRepo = vi.fn().mockResolvedValue({ ok: true }); + const refreshGitRemote = vi.fn(); + + const { result } = renderHook(() => + useInitGitRepoPrompt({ + activeWorkspace: workspace, + initGitRepo, + createGitHubRepo, + refreshGitRemote, + isBusy: false, + }), + ); + + act(() => { + result.current.openInitGitRepoPrompt(); + }); + + await act(async () => { + await result.current.handleInitGitRepoPromptConfirm(); + }); + + expect(result.current.initGitRepoPrompt?.error).toBe( + "Failed to initialize Git repository.", + ); + }); +}); + diff --git a/src/features/git/hooks/useInitGitRepoPrompt.ts b/src/features/git/hooks/useInitGitRepoPrompt.ts index bf327a5b8..a6f6fea7a 100644 --- a/src/features/git/hooks/useInitGitRepoPrompt.ts +++ b/src/features/git/hooks/useInitGitRepoPrompt.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import type { WorkspaceInfo } from "../../../types"; import { validateBranchName } from "../utils/branchValidation"; +import type { InitGitRepoOutcome } from "./useGitActions"; type InitGitRepoPromptState = { workspaceId: string; @@ -20,7 +21,7 @@ export function useInitGitRepoPrompt({ isBusy, }: { activeWorkspace: WorkspaceInfo | null; - initGitRepo: (branch: string) => Promise; + initGitRepo: (branch: string) => Promise; createGitHubRepo: ( repo: string, visibility: "private" | "public", @@ -163,8 +164,14 @@ export function useInitGitRepoPrompt({ return; } - const ok = await initGitRepo(trimmedBranch); - if (!ok) { + setInitGitRepoPrompt((prev) => (prev ? { ...prev, error: null } : prev)); + + const initOutcome = await initGitRepo(trimmedBranch); + if (initOutcome === "cancelled") { + return; + } + + if (initOutcome !== "initialized") { setInitGitRepoPrompt((prev) => prev ? { ...prev, error: prev.error ?? "Failed to initialize Git repository." } : prev, ); From d06f760196e5cad856d4a12001d3f9237f490267 Mon Sep 17 00:00:00 2001 From: Samigos Date: Wed, 11 Feb 2026 14:59:47 +0200 Subject: [PATCH 6/8] Place git init button above git root actions --- src/features/git/components/GitDiffPanelModeContent.tsx | 8 +++++--- src/styles/diff.css | 4 ++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/features/git/components/GitDiffPanelModeContent.tsx b/src/features/git/components/GitDiffPanelModeContent.tsx index 534e512c7..c7b18d66d 100644 --- a/src/features/git/components/GitDiffPanelModeContent.tsx +++ b/src/features/git/components/GitDiffPanelModeContent.tsx @@ -284,8 +284,8 @@ export function GitDiffModeContent({ {showGitRootPanel && (
{gitRootTitle}
-
- {showInitGitRepo && ( + {showInitGitRepo && ( +
- )} +
+ )} +