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
15 changes: 12 additions & 3 deletions src/tinyleaf/git_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ def list_branches(project_dir):
rc, out, _ = _run_git(project_dir, "branch", "--list", "--no-color")
if rc == 0:
for line in out.split("\n"):
line = line.lstrip("* ").strip()
line = line.removeprefix("* ").strip()
if line.startswith("("):
continue
if line:
local.append(line)

Expand Down Expand Up @@ -408,6 +410,12 @@ def worktree_list(project_dir):
worktrees.append(current)
current = {}

if current:
current.setdefault("head", "")
current.setdefault("branch", "")
current["is_main"] = len(worktrees) == 0
worktrees.append(current)

return worktrees


Expand All @@ -425,7 +433,7 @@ def worktree_add(project_dir, path, branch):
if not has_git(project_dir):
return {"success": False, "path": "", "message": "Not a git repository"}

rc, out, err = _run_git(project_dir, "worktree", "add", path, branch)
rc, out, err = _run_git(project_dir, "worktree", "add", "--", path, branch)
if rc != 0:
return {"success": False, "path": "", "message": err or out}
return {"success": True, "path": os.path.abspath(path), "message": (out + err).strip()}
Expand All @@ -445,9 +453,10 @@ def worktree_remove(project_dir, path, force=False):
if not has_git(project_dir):
return {"success": False, "message": "Not a git repository"}

args = ["worktree", "remove", path]
args = ["worktree", "remove"]
if force:
args.append("--force")
args.extend(["--", path])
rc, out, err = _run_git(project_dir, *args)
if rc != 0:
return {"success": False, "message": err or out}
Expand Down
21 changes: 15 additions & 6 deletions src/tinyleaf/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1122,10 +1122,11 @@ def handle_git_branches(query_params, config, name):
"""List branches, optionally fetching first."""
project_dir = _get_project_dir(config, name)

result = git_ops.list_branches(project_dir)
if query_params.get("fetch", [""])[0] == "true":
git_ops.fetch(project_dir)

return git_ops.list_branches(project_dir)
fetch_result = git_ops.fetch(project_dir)
result["fetch"] = fetch_result
return result


def handle_git_switch_branch(body, config, name):
Expand Down Expand Up @@ -1262,13 +1263,21 @@ def handle_git_worktree_remove(body, config, name):


def handle_git_worktree_switch(body, config, name):
"""Switch the active project to a different worktree path."""
"""Switch the active project to a different worktree path.

Only supported in single-project mode. The change is ephemeral —
on server restart, project_path reverts to the original value.
"""
if config["mode"] != "single":
abort(400, "Worktree switch is only supported in single-project mode")

path = body.get("path", "").strip()
if not path:
abort(400, "Worktree path required")
if not os.path.isdir(path):
abort(404, f"Worktree directory not found: {path}")
if not git_ops.has_git(path):
abort(400, f"Not a git repository or worktree: {path}")

if config["mode"] == "single":
config["project_path"] = path
config["project_path"] = path
return {"success": True, "path": path}
178 changes: 150 additions & 28 deletions src/tinyleaf/static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ const I18N = {
worktree_main: "main",
worktree_switching: "Switching worktree...",
open_as_worktree: "Open as worktree",
create_worktree: "Create worktree",
select_branch: "Branch",
unsaved_changes: "You have unsaved changes in the editor",
save_and_continue: "Save & Continue",
discard_and_continue: "Continue without saving",
dirty_working_tree: "You have uncommitted changes",
dirty_stash_and_switch: "Stash & Switch",
dirty_discard_and_switch: "Switch anyway",
Expand Down Expand Up @@ -370,6 +375,11 @@ const I18N = {
worktree_main: "主",
worktree_switching: "切换工作树中...",
open_as_worktree: "在工作树中打开",
create_worktree: "创建工作树",
select_branch: "分支",
unsaved_changes: "编辑器中有未保存的更改",
save_and_continue: "保存并继续",
discard_and_continue: "不保存直接继续",
dirty_working_tree: "存在未提交的更改",
dirty_stash_and_switch: "暂存并切换",
dirty_discard_and_switch: "直接切换",
Expand Down Expand Up @@ -3997,6 +4007,8 @@ async function refreshWorktrees() {

item.appendChild(info);

item.onclick = () => switchWorktree(wt.path);

if (!wt.is_main) {
const actions = document.createElement("div");
actions.className = "git-worktree-actions";
Expand All @@ -4006,8 +4018,6 @@ async function refreshWorktrees() {
removeBtn.onclick = (e) => { e.stopPropagation(); removeWorktree(wt.path, wt.branch); };
actions.appendChild(removeBtn);
item.appendChild(actions);

item.onclick = () => switchWorktree(wt.path);
}

list.appendChild(item);
Expand All @@ -4018,6 +4028,50 @@ async function refreshWorktrees() {
}

async function switchWorktree(path) {
// Check for unsaved editor changes before switching
if (S.currentFile && S.modified.has(S.currentFile)) {
const action = await new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.className = "git-dialog-overlay";
const dialog = document.createElement("div");
dialog.className = "git-dialog";

const title = document.createElement("h3");
title.textContent = t("unsaved_changes");
dialog.appendChild(title);

const btns = document.createElement("div");
btns.className = "git-dialog-buttons";

const cancelBtn = document.createElement("button");
cancelBtn.className = "sm";
cancelBtn.textContent = t("cancel");
cancelBtn.onclick = () => { overlay.remove(); resolve("cancel"); };

const discardBtn = document.createElement("button");
discardBtn.className = "sm";
discardBtn.textContent = t("discard_and_continue");
discardBtn.onclick = () => { overlay.remove(); resolve("discard"); };

const saveBtn = document.createElement("button");
saveBtn.className = "sm primary";
saveBtn.textContent = t("save_and_continue");
saveBtn.onclick = () => { overlay.remove(); resolve("save"); };

btns.appendChild(cancelBtn);
btns.appendChild(discardBtn);
btns.appendChild(saveBtn);
dialog.appendChild(btns);

overlay.appendChild(dialog);
document.body.appendChild(overlay);
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve("cancel"); } };
});
if (action === "cancel") return;
if (action === "save") await saveCurrentFile();
if (action === "discard") S.modified.delete(S.currentFile);
}

setStatus(t("worktree_switching"));
try {
const result = await api("POST", `/api/projects/${enc(S.projectName)}/git/worktrees/switch`, { path });
Expand All @@ -4038,49 +4092,84 @@ async function switchWorktree(path) {
}

async function addWorktreeDialog() {
// Reuse the git-dialog pattern from the branch selector
const overlay = document.createElement("div");
overlay.className = "git-dialog-overlay";
const dialog = document.createElement("div");
dialog.className = "git-dialog";

// Fetch branches for dropdown
const branches = await loadBranches();
let branches;
try {
branches = await loadBranches();
} catch {
setStatus("Failed to load branches", "error");
return;
}
const availableBranches = branches.local.filter(b => b !== branches.current);

let branchOptions = availableBranches.map(b => `<option value="${b}">${b}</option>`).join("");
if (branchOptions === "") {
branchOptions = `<option value="" disabled>${t("no_worktrees")}</option>`;
// Title
const title = document.createElement("h3");
title.textContent = t("add_worktree").replace(/^\+\s*/, "");
dialog.appendChild(title);

// Branch select
const selectDiv = document.createElement("div");
selectDiv.style.cssText = "margin-bottom:12px";
const selectLabel = document.createElement("label");
selectLabel.style.cssText = "font-size:12px;color:var(--fg-muted);display:block;margin-bottom:4px";
selectLabel.textContent = t("select_branch");
selectDiv.appendChild(selectLabel);

const select = document.createElement("select");
select.id = "worktree-branch-select";
select.style.cssText = "width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg);color:var(--fg);font-size:13px;box-sizing:border-box";

if (availableBranches.length === 0) {
const opt = document.createElement("option");
opt.value = "";
opt.disabled = true;
opt.textContent = t("no_worktrees");
select.appendChild(opt);
} else {
for (const b of availableBranches) {
const opt = document.createElement("option");
opt.value = b;
opt.textContent = b;
select.appendChild(opt);
}
}
selectDiv.appendChild(select);
dialog.appendChild(selectDiv);

// Buttons
const btns = document.createElement("div");
btns.className = "git-dialog-buttons";
const cancelBtn = document.createElement("button");
cancelBtn.className = "sm";
cancelBtn.textContent = t("cancel");
const okBtn = document.createElement("button");
okBtn.className = "sm primary";
okBtn.textContent = t("create_worktree");
if (availableBranches.length === 0) okBtn.disabled = true;
btns.appendChild(cancelBtn);
btns.appendChild(okBtn);
dialog.appendChild(btns);

dialog.innerHTML = `
<h3>${t("add_worktree")}</h3>
<div style="margin-bottom:12px">
<label style="font-size:12px;color:var(--fg-muted);display:block;margin-bottom:4px">${t("switch_branch")}</label>
<select id="worktree-branch-select" style="width:100%;padding:6px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg);color:var(--fg);font-size:13px;box-sizing:border-box">
${branchOptions}
</select>
</div>
<div class="git-dialog-buttons">
<button class="sm" id="worktree-cancel">${t("cancel")}</button>
<button class="sm primary" id="worktree-ok">${t("create_branch")}</button>
</div>
`;
overlay.appendChild(dialog);
document.body.appendChild(overlay);

document.getElementById("worktree-cancel").onclick = () => overlay.remove();
cancelBtn.onclick = () => overlay.remove();
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };

document.getElementById("worktree-ok").onclick = async () => {
const branch = document.getElementById("worktree-branch-select").value;
okBtn.onclick = async () => {
const branch = select.value;
if (!branch) return;
overlay.remove();
setStatus(t("loading"));
try {
const result = await api("POST", `/api/projects/${enc(S.projectName)}/git/worktrees`, { branch });
if (result.success) {
setStatus(`${t("worktree_created")}: ${branch}`, "success");
refreshWorktrees();
await refreshWorktrees();
} else {
setStatus(result.message, "error");
}
Expand All @@ -4091,12 +4180,44 @@ async function addWorktreeDialog() {
}

async function removeWorktree(path, branchName) {
if (!confirm(t("confirm_remove_worktree").replace("{name}", branchName))) return;
const confirmed = await new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.className = "git-dialog-overlay";
const dialog = document.createElement("div");
dialog.className = "git-dialog";

const title = document.createElement("h3");
title.textContent = t("confirm_remove_worktree").replace("{name}", branchName);
dialog.appendChild(title);

const btns = document.createElement("div");
btns.className = "git-dialog-buttons";

const cancelBtn = document.createElement("button");
cancelBtn.className = "sm";
cancelBtn.textContent = t("cancel");
cancelBtn.onclick = () => { overlay.remove(); resolve(false); };

const okBtn = document.createElement("button");
okBtn.className = "sm primary";
okBtn.textContent = t("remove_worktree");
okBtn.onclick = () => { overlay.remove(); resolve(true); };

btns.appendChild(cancelBtn);
btns.appendChild(okBtn);
dialog.appendChild(btns);

overlay.appendChild(dialog);
document.body.appendChild(overlay);
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(false); } };
});
if (!confirmed) return;

try {
const result = await api("DELETE", `/api/projects/${enc(S.projectName)}/git/worktrees`, { path });
if (result.success) {
setStatus(`${t("worktree_removed")}: ${branchName}`, "success");
refreshWorktrees();
await refreshWorktrees();
} else {
setStatus(result.message, "error");
}
Expand All @@ -4106,11 +4227,12 @@ async function removeWorktree(path, branchName) {
}

async function openAsWorktree(branch) {
setStatus(t("loading"));
try {
const result = await api("POST", `/api/projects/${enc(S.projectName)}/git/worktrees`, { branch });
if (result.success) {
setStatus(`${t("worktree_created")}: ${branch}`, "success");
// Switch to versions tab to show the new worktree
await switchWorktree(result.path);
switchGitSubTab("versions");
} else {
setStatus(result.message, "error");
Expand Down
Loading