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
1 change: 1 addition & 0 deletions Backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ fn build_invoke_handler<R: tauri::Runtime>() -> impl Fn(tauri::ipc::Invoke<R>) -
tauri_commands::git_head_status,
tauri_commands::git_checkout_branch,
tauri_commands::git_create_branch,
tauri_commands::git_rename_branch,
tauri_commands::git_current_branch,
tauri_commands::get_repo_summary,
tauri_commands::open_repo,
Expand Down
83 changes: 74 additions & 9 deletions Backend/src/menus.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
use tauri::{async_runtime, menu, Emitter};
use tauri::{async_runtime, menu, Emitter, Manager};
use tauri::menu::{Menu, MenuBuilder, MenuEvent, MenuItem};
use tauri_plugin_opener::OpenerExt;

use crate::utilities::utilities;
use crate::state::AppState;
use std::fs::OpenOptions;
use std::path::PathBuf;

const WIKI_URL: &str = "https://github.com/jordonbc/OpenVCS/wiki";

Expand All @@ -29,14 +32,34 @@ fn build_file_menu<R: tauri::Runtime>(app: &tauri::App<R>) -> tauri::Result<menu
let open_item = MenuItem::with_id(app, "open_repo", "Switch…", true, Some("Ctrl+R"))?;
let prefs_item = MenuItem::with_id(app, "settings", "Preferences…", true, Some("Ctrl+P"))?;

menu::SubmenuBuilder::new(app, "File")
.item(&clone_item)
.item(&add_item)
.item(&open_item)
.separator()
.item(&menu::PredefinedMenuItem::quit(app, None)?)
.item(&prefs_item)
.build()
// macOS: keep native Quit in the App/File menu
#[cfg(target_os = "macos")]
{
return menu::SubmenuBuilder::new(app, "File")
.item(&clone_item)
.item(&add_item)
.item(&open_item)
.separator()
.item(&prefs_item)
.separator()
.item(&menu::PredefinedMenuItem::quit(app, None)?)
.build();
}

// Other platforms: add explicit "Exit" item
#[cfg(not(target_os = "macos"))]
{
let exit_item = MenuItem::with_id(app, "exit", "Exit", true, None::<&str>)?;
return menu::SubmenuBuilder::new(app, "File")
.item(&clone_item)
.item(&add_item)
.item(&open_item)
.separator()
.item(&prefs_item)
.separator()
.item(&exit_item)
.build();
}
}

/// ----- Edit -----
Expand Down Expand Up @@ -66,10 +89,15 @@ fn build_repository_menu<R: tauri::Runtime>(app: &tauri::App<R>) -> tauri::Resul
let push_item = MenuItem::with_id(app, "push", "Push", true, Some("Ctrl+P"))?;
let commit_item = MenuItem::with_id(app, "commit", "Commit", true, Some("Ctrl+Enter"))?;
let repo_settings_item = MenuItem::with_id(app, "repo-settings", "Repository Settings", true, None::<&str>)?;
let edit_gitignore_item = MenuItem::with_id(app, "repo-edit-gitignore", "Edit .gitignore", true, None::<&str>)?;
let edit_gitattributes_item = MenuItem::with_id(app, "repo-edit-gitattributes", "Edit .gitattributes", true, None::<&str>)?;
menu::SubmenuBuilder::new(app, "Repository")
.item(&fetch_item)
.item(&push_item)
.item(&commit_item)
.separator()
.item(&edit_gitignore_item)
.item(&edit_gitattributes_item)
.item(&repo_settings_item)
.build()
}
Expand All @@ -89,9 +117,19 @@ fn build_help_menu<R: tauri::Runtime>(app: &tauri::App<R>) -> tauri::Result<menu
pub fn handle_menu_event<R: tauri::Runtime>(app: &tauri::AppHandle<R>, event: MenuEvent) {
let id = event.id().0.to_string();
match id.as_str() {
"exit" => {
// Gracefully exit the application
app.exit(0);
}
"docs" => {
let _ = app.opener().open_url(WIKI_URL, None::<&str>);
}
"repo-edit-gitignore" => {
open_repo_dotfile(app, ".gitignore");
}
"repo-edit-gitattributes" => {
open_repo_dotfile(app, ".gitattributes");
}
"add_repo" => {
let app_cloned = app.clone();
async_runtime::spawn(async move {
Expand Down Expand Up @@ -119,3 +157,30 @@ pub fn handle_menu_event<R: tauri::Runtime>(app: &tauri::AppHandle<R>, event: Me
}
}
}

/// Open or create a repository dotfile in the user's default editor.
fn open_repo_dotfile<R: tauri::Runtime>(app: &tauri::AppHandle<R>, name: &str) {
// Resolve current repo path from managed state
let state = app.state::<AppState>();
let root = match state.current_repo() {
Some(repo) => repo.inner().workdir().to_path_buf(),
None => {
let _ = app.emit("ui:notify", "No repository selected");
return;
}
};

let mut path = PathBuf::from(root);
path.push(name);

// Ensure the file exists (create if missing)
if !path.exists() {
let _ = OpenOptions::new()
.create(true)
.write(true)
.open(&path);
}

// Open with system default editor/handler
let _ = app.opener().open_path(path.to_string_lossy().to_string(), None::<&str>);
}
11 changes: 11 additions & 0 deletions Backend/src/tauri_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,17 @@ pub fn git_delete_branch(state: State<'_, AppState>, name: String, force: Option
vcs.delete_branch(name, force.unwrap_or(false)).map_err(|e| e.to_string())
}

#[tauri::command]
pub fn git_rename_branch(state: State<'_, AppState>, old_name: String, new_name: String) -> Result<(), String> {
let old = old_name.trim();
let newn = new_name.trim();
if old.is_empty() || newn.is_empty() { return Err("Branch name cannot be empty".into()); }
if old == newn { return Ok(()); }
let repo = state.current_repo().ok_or_else(|| "No repository selected".to_string())?;
let vcs = repo.inner();
vcs.rename_branch(old, newn).map_err(|e| e.to_string())
}

#[tauri::command]
pub fn git_merge_branch(state: State<'_, AppState>, name: String) -> Result<(), String> {
let name = name.trim();
Expand Down
28 changes: 28 additions & 0 deletions Frontend/src/modals/rename-branch.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<!-- Rename Branch modal -->
<div class="modal" id="rename-branch-modal" aria-hidden="true">
<div class="backdrop"></div>
<div class="dialog sheet" role="dialog" aria-modal="true" aria-labelledby="rename-branch-title">
<div class="sheet-head">
<h3 id="rename-branch-title" style="margin:0">Rename Branch</h3>
<button class="tbtn" data-close aria-label="Close">✕</button>
</div>
<div class="sheet-body">
<form id="rename-branch-form" class="panel-form" onsubmit="return false;">
<div class="row">
<label for="rename-branch-current">Current name</label>
<input id="rename-branch-current" type="text" readonly />
</div>
<div class="row">
<label for="rename-branch-name">New name</label>
<input id="rename-branch-name" type="text" placeholder="e.g. feature/new-name" />
</div>
</form>
</div>
<div class="sheet-actions" style="display:flex; gap:.5rem; justify-content:flex-end;">
<button class="tbtn" data-close type="button">Cancel</button>
<button class="tbtn primary" id="rename-branch-confirm" type="button" disabled>Rename</button>
</div>
</div>

</div>

2 changes: 2 additions & 0 deletions Frontend/src/scripts/features/branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { TAURI } from '../lib/tauri';
import { notify } from '../lib/notify';
import { state } from '../state/state';
import { openModal } from '../ui/modals';
import { openRenameBranch } from './renameBranch';
import { buildCtxMenu } from '../lib/menu';
import { renderList } from './repo';

Expand Down Expand Up @@ -127,6 +128,7 @@ export function bindBranchUI() {
}});
if (kind !== 'remote') {
items.push({ label: '---', action: () => {} });
items.push({ label: 'Rename…', action: () => openRenameBranch(name) });
items.push({ label: wantForce ? 'Force delete…' : 'Delete…', action: async () => {
if (name === cur) { notify('Cannot delete the current branch'); return; }
const ok = window.confirm(`${wantForce ? 'Force delete' : 'Delete'} local branch '${name}'? This cannot be undone.`);
Expand Down
58 changes: 58 additions & 0 deletions Frontend/src/scripts/features/renameBranch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// src/scripts/features/renameBranch.ts
import { TAURI } from "../lib/tauri";
import { notify } from "../lib/notify";
import { closeModal, hydrate, openModal } from "../ui/modals";

export function wireRenameBranch() {
const modal = document.getElementById('rename-branch-modal') as HTMLElement | null;
if (!modal || (modal as any).__wired) return;
(modal as any).__wired = true;

const currentEl = modal.querySelector<HTMLInputElement>('#rename-branch-current');
const nameEl = modal.querySelector<HTMLInputElement>('#rename-branch-name');
const confirm = modal.querySelector<HTMLButtonElement>('#rename-branch-confirm');

function validate() {
const oldName = (modal?.dataset.oldBranch || '').trim();
const newName = (nameEl?.value || '').trim();
const ok = !!newName && newName !== oldName;
if (confirm) confirm.disabled = !ok;
}

nameEl?.addEventListener('input', validate);
nameEl?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); confirm?.click(); }
});

confirm?.addEventListener('click', async () => {
const oldName = (modal?.dataset.oldBranch || '').trim();
const newName = (nameEl?.value || '').trim();
if (!oldName || !newName || oldName === newName) return;
try {
if (TAURI.has) await TAURI.invoke('git_rename_branch', { old_name: oldName, new_name: newName });
notify(`Renamed '${oldName}' → '${newName}'`);
// Ask the rest of the app to refresh branch UI
window.dispatchEvent(new CustomEvent('app:repo-selected'));
closeModal('rename-branch-modal');
} catch (e) {
notify(`Rename failed${e ? `: ${e}` : ''}`);
}
});

// Expose a small API for initializing the modal per open
(modal as any).setInitial = (oldName: string) => {
modal.dataset.oldBranch = oldName;
if (currentEl) currentEl.value = oldName;
if (nameEl) { nameEl.value = oldName; setTimeout(() => { nameEl.focus(); nameEl.select(); validate(); }, 0); }
};
}

export function openRenameBranch(oldName: string) {
// Ensure modal exists and is wired
hydrate('rename-branch-modal');
wireRenameBranch();
const modal = document.getElementById('rename-branch-modal') as any;
modal?.setInitial?.(oldName);
openModal('rename-branch-modal');
}

4 changes: 4 additions & 0 deletions Frontend/src/scripts/ui/modals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import repoSettingsHtml from "@modals/repo-settings.html?raw";
import { wireRepoSettings } from "../features/repoSettings";
import newBranchHtml from "@modals/new-branch.html?raw";
import { wireNewBranch } from "../features/newBranch";
import renameBranchHtml from "@modals/rename-branch.html?raw";
import { wireRenameBranch } from "../features/renameBranch";

// Lazy fragments (only those NOT present at load)
const FRAGMENTS: Record<string, string> = {
Expand All @@ -16,6 +18,7 @@ const FRAGMENTS: Record<string, string> = {
"command-modal": cmdHtml,
"repo-settings-modal": repoSettingsHtml,
"new-branch-modal": newBranchHtml,
"rename-branch-modal": renameBranchHtml,
};

const loaded = new Set<string>();
Expand Down Expand Up @@ -53,6 +56,7 @@ export function hydrate(id: string): void {
if (id === "settings-modal") wireSettings();
if (id === "repo-settings-modal") wireRepoSettings();
if (id === "new-branch-modal") wireNewBranch();
if (id === "rename-branch-modal") wireRenameBranch();
}

export function openModal(id: string): void {
Expand Down
2 changes: 2 additions & 0 deletions crates/openvcs-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ pub trait Vcs: Send + Sync {

// branches
fn delete_branch(&self, name: &str, force: bool) -> Result<()>;
/// Rename a local branch from `old` to `new`.
fn rename_branch(&self, old: &str, new: &str) -> Result<()>;
/// Merge the given branch into the current HEAD. Implementations may return
/// `VcsError::Unsupported` if not available.
fn merge_into_current(&self, name: &str) -> Result<()>;
Expand Down
9 changes: 9 additions & 0 deletions crates/openvcs-git-libgit2/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,15 @@ impl Vcs for GitLibGit2 {
}).map_err(Self::map_err::<git2::Error>)
}

fn rename_branch(&self, old: &str, new: &str) -> Result<()> {
self.inner.with_repo(|repo| {
use git2 as g;
let mut br = repo.find_branch(old, g::BranchType::Local)?;
br.rename(new, false)?; // do not force; let libgit2 report conflicts
Ok(())
}).map_err(Self::map_err::<git2::Error>)
}

fn merge_into_current(&self, _name: &str) -> Result<()> {
Err(VcsError::Unsupported(GIT_LIBGIT2_ID))
}
Expand Down
10 changes: 10 additions & 0 deletions crates/openvcs-git/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,16 @@ impl Vcs for GitSystem {
}
}

fn rename_branch(&self, old: &str, new: &str) -> Result<()> {
let old = old.trim();
let new = new.trim();
if old.is_empty() || new.is_empty() {
return Err(VcsError::Backend { backend: GIT_SYSTEM_ID, msg: "branch names cannot be empty".into() });
}
// Use git's builtin rename which preserves upstream/tracking when possible
Self::run_git(Some(&self.workdir), ["branch", "-m", old, new])
}

fn merge_into_current(&self, name: &str) -> Result<()> {
// Perform a merge into the current branch. Let git promptless merge and return any conflicts as error output.
Self::run_git(Some(&self.workdir), ["merge", "--no-ff", name])
Expand Down
Loading