feat(worktree): guider la sélection de branche cible

closes #6
This commit is contained in:
thibaud-lclr 2026-04-16 17:35:40 +02:00
parent 0a2e7daec9
commit 45c51730ec
4 changed files with 151 additions and 10 deletions

View file

@ -43,6 +43,11 @@ pub fn apply_fix_to_branch(
worktree_id: String, worktree_id: String,
target_branch: String, target_branch: String,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let target_branch = target_branch.trim().to_string();
if target_branch.is_empty() {
return Err(AppError::from("Target branch is required".to_string()));
}
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?; let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
let wt = Worktree::get_by_id(&conn, &worktree_id)?; let wt = Worktree::get_by_id(&conn, &worktree_id)?;
@ -52,6 +57,15 @@ pub fn apply_fix_to_branch(
drop(conn); drop(conn);
let local_branches =
worktree_manager::list_local_branches(&project.path).map_err(AppError::from)?;
if !local_branches.iter().any(|branch| branch == &target_branch) {
return Err(AppError::from(format!(
"Target branch '{}' does not exist locally",
target_branch
)));
}
worktree_manager::apply_fix( worktree_manager::apply_fix(
&project.path, &project.path,
&project.base_branch, &project.base_branch,
@ -102,3 +116,23 @@ pub fn list_local_branches(
let branches = worktree_manager::list_local_branches(&project.path).map_err(AppError::from)?; let branches = worktree_manager::list_local_branches(&project.path).map_err(AppError::from)?;
Ok(branches) Ok(branches)
} }
#[tauri::command]
pub fn list_local_branches_for_worktree(
state: State<'_, AppState>,
worktree_id: String,
) -> Result<Vec<String>, AppError> {
let conn = state.db.lock().map_err(|e| AppError::from(e.to_string()))?;
let wt = Worktree::get_by_id(&conn, &worktree_id)?;
let ticket = ProcessedTicket::get_by_id(&conn, &wt.ticket_id)?;
let tracker = WatchedTracker::get_by_id(&conn, &ticket.tracker_id)?;
let project = Project::get_by_id(&conn, &tracker.project_id)?;
drop(conn);
let mut branches =
worktree_manager::list_local_branches(&project.path).map_err(AppError::from)?;
branches.sort();
Ok(branches)
}

View file

@ -106,6 +106,7 @@ pub fn run() {
commands::worktree::apply_fix_to_branch, commands::worktree::apply_fix_to_branch,
commands::worktree::delete_worktree_cmd, commands::worktree::delete_worktree_cmd,
commands::worktree::list_local_branches, commands::worktree::list_local_branches,
commands::worktree::list_local_branches_for_worktree,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View file

@ -8,6 +8,7 @@ import {
deleteWorktreeCmd, deleteWorktreeCmd,
getTicketResult, getTicketResult,
getWorktreeDiff, getWorktreeDiff,
listLocalBranchesForWorktree,
retryTicket, retryTicket,
} from "../../lib/api"; } from "../../lib/api";
import { getErrorMessage } from "../../lib/errors"; import { getErrorMessage } from "../../lib/errors";
@ -65,11 +66,41 @@ export default function TicketDetail() {
const [worktree, setWorktree] = useState<Worktree | null>(null); const [worktree, setWorktree] = useState<Worktree | null>(null);
const [diff, setDiff] = useState<string | null>(null); const [diff, setDiff] = useState<string | null>(null);
const [targetBranch, setTargetBranch] = useState(""); const [targetBranch, setTargetBranch] = useState("");
const [availableBranches, setAvailableBranches] = useState<string[]>([]);
const [branchInputMode, setBranchInputMode] = useState<"select" | "manual">("select");
const [branchesLoading, setBranchesLoading] = useState(false);
const [branchesError, setBranchesError] = useState("");
const [tab, setTab] = useState<"info" | "analyst" | "developer" | "diff">("info"); const [tab, setTab] = useState<"info" | "analyst" | "developer" | "diff">("info");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [isDeleteWorktreeModalOpen, setIsDeleteWorktreeModalOpen] = useState(false); const [isDeleteWorktreeModalOpen, setIsDeleteWorktreeModalOpen] = useState(false);
async function loadBranchOptions(worktreeId: string) {
setBranchesLoading(true);
setBranchesError("");
try {
const branches = await listLocalBranchesForWorktree(worktreeId);
setAvailableBranches(branches);
if (branches.length > 0) {
setBranchInputMode("select");
setTargetBranch((prev) => {
const trimmedPrev = prev.trim();
if (trimmedPrev && branches.includes(trimmedPrev)) return trimmedPrev;
return branches[0];
});
} else {
setBranchInputMode("manual");
}
} catch (err) {
setAvailableBranches([]);
setBranchInputMode("manual");
setBranchesError(getErrorMessage(err));
} finally {
setBranchesLoading(false);
}
}
async function loadData() { async function loadData() {
if (!ticketId) return; if (!ticketId) return;
try { try {
@ -81,6 +112,7 @@ export default function TicketDetail() {
else if (result.ticket.analyst_report) setTab("analyst"); else if (result.ticket.analyst_report) setTab("analyst");
if (result.worktree && result.worktree.status === "Active") { if (result.worktree && result.worktree.status === "Active") {
await loadBranchOptions(result.worktree.id);
try { try {
const d = await getWorktreeDiff(result.worktree.id); const d = await getWorktreeDiff(result.worktree.id);
setDiff(d); setDiff(d);
@ -89,6 +121,11 @@ export default function TicketDetail() {
} }
} else { } else {
setDiff(null); setDiff(null);
setAvailableBranches([]);
setBranchInputMode("select");
setTargetBranch("");
setBranchesError("");
setBranchesLoading(false);
} }
} catch (err) { } catch (err) {
setError(getErrorMessage(err)); setError(getErrorMessage(err));
@ -124,11 +161,27 @@ export default function TicketDetail() {
} }
async function handleApplyFix() { async function handleApplyFix() {
if (!worktree || !targetBranch) return; if (!worktree) return;
const normalizedTargetBranch = targetBranch.trim();
if (!normalizedTargetBranch) {
setError("Veuillez sélectionner ou saisir une branche cible.");
return;
}
if (
availableBranches.length > 0 &&
!availableBranches.includes(normalizedTargetBranch)
) {
setError(
`La branche cible "${normalizedTargetBranch}" n'existe pas localement.`
);
return;
}
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
await applyFixToBranch(worktree.id, targetBranch); await applyFixToBranch(worktree.id, normalizedTargetBranch);
await loadData(); await loadData();
} catch (err) { } catch (err) {
setError(getErrorMessage(err)); setError(getErrorMessage(err));
@ -144,6 +197,11 @@ export default function TicketDetail() {
await deleteWorktreeCmd(worktree.id); await deleteWorktreeCmd(worktree.id);
setWorktree(null); setWorktree(null);
setDiff(null); setDiff(null);
setAvailableBranches([]);
setTargetBranch("");
setBranchInputMode("select");
setBranchesError("");
setBranchesLoading(false);
} catch (err) { } catch (err) {
setError(getErrorMessage(err)); setError(getErrorMessage(err));
} }
@ -274,22 +332,67 @@ export default function TicketDetail() {
{worktree && worktree.status === "Active" && ( {worktree && worktree.status === "Active" && (
<div className="rounded-lg border border-gray-200 bg-white p-4"> <div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="mb-3 text-sm font-semibold">Worktree Actions</h3> <h3 className="mb-3 text-sm font-semibold">Worktree Actions</h3>
<div className="mb-2 flex items-center justify-between">
<p className="text-xs text-gray-500">Target branch</p>
{availableBranches.length > 0 && (
<button
type="button"
onClick={() =>
setBranchInputMode((mode) => (mode === "select" ? "manual" : "select"))
}
className="text-xs text-blue-600 hover:underline"
>
{branchInputMode === "select"
? "Use manual input"
: "Choose existing branch"}
</button>
)}
</div>
<div className="mb-3 flex items-center gap-2"> <div className="mb-3 flex items-center gap-2">
<input {branchInputMode === "select" && availableBranches.length > 0 ? (
type="text" <select
placeholder="Target branch (e.g. feature/login)" value={targetBranch}
value={targetBranch} onChange={(e) => setTargetBranch(e.target.value)}
onChange={(e) => setTargetBranch(e.target.value)} className="flex-1 rounded border border-gray-300 bg-white px-3 py-1.5 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500"
className="flex-1 rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500" >
/> {availableBranches.map((branch) => (
<option key={branch} value={branch}>
{branch}
</option>
))}
</select>
) : (
<input
type="text"
placeholder="Target branch (e.g. feature/login)"
value={targetBranch}
onChange={(e) => setTargetBranch(e.target.value)}
className="flex-1 rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-transparent focus:ring-2 focus:ring-blue-500"
/>
)}
<button <button
onClick={handleApplyFix} onClick={handleApplyFix}
disabled={loading || !targetBranch} disabled={loading || branchesLoading || !targetBranch.trim()}
className="rounded bg-green-600 px-3 py-1.5 text-sm text-white hover:bg-green-700 disabled:opacity-50" className="rounded bg-green-600 px-3 py-1.5 text-sm text-white hover:bg-green-700 disabled:opacity-50"
> >
Apply fix Apply fix
</button> </button>
</div> </div>
{branchesLoading && <p className="mb-3 text-xs text-gray-500">Loading local branches...</p>}
{!branchesLoading && branchesError && (
<p className="mb-3 text-xs text-amber-700">
Could not preload branches. Manual input is still available.
</p>
)}
{!branchesLoading &&
!branchesError &&
availableBranches.length === 0 &&
branchInputMode === "manual" && (
<p className="mb-3 text-xs text-gray-500">
No local branches detected. Enter the target branch manually.
</p>
)}
<button <button
onClick={() => setIsDeleteWorktreeModalOpen(true)} onClick={() => setIsDeleteWorktreeModalOpen(true)}
disabled={loading} disabled={loading}

View file

@ -194,6 +194,9 @@ export async function deleteWorktreeCmd(worktreeId: string): Promise<void> {
export async function listLocalBranches(projectId: string): Promise<string[]> { export async function listLocalBranches(projectId: string): Promise<string[]> {
return invoke("list_local_branches", { projectId }); return invoke("list_local_branches", { projectId });
} }
export async function listLocalBranchesForWorktree(worktreeId: string): Promise<string[]> {
return invoke("list_local_branches_for_worktree", { worktreeId });
}
// Notifications // Notifications
export async function listNotifications( export async function listNotifications(