Skip to content

Commit a479ff2

Browse files
committed
Improved button labelling
1 parent a4c7228 commit a479ff2

13 files changed

Lines changed: 198 additions & 27 deletions

File tree

ARCHITECTURE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Backend:
4444
Feature-facing backend API lives under `Backend/src/tauri_commands/`.
4545
- Backend/plugin boundary:
4646
Backend communicates with plugin processes over JSON-RPC over stdio.
47+
- Repo-open UI labels can be resolved from the active backend via backend-provided action-label maps; generic VCS text remains the fallback.
4748
- Settings boundary:
4849
Backend persists/loads app configuration and mediates environment application.
4950

Backend/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,7 @@ fn build_invoke_handler<R: tauri::Runtime>(
322322
tauri_commands::current_repo_path,
323323
tauri_commands::list_recent_repos,
324324
tauri_commands::vcs_list_branches,
325+
tauri_commands::current_vcs_action_labels,
325326
tauri_commands::vcs_status,
326327
tauri_commands::vcs_log,
327328
tauri_commands::vcs_stash_list,

Backend/src/plugin_bundles.rs

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ pub enum VcsBackendProvide {
112112
id: String,
113113
#[serde(default)]
114114
name: Option<String>,
115+
#[serde(default)]
116+
action_labels: BTreeMap<String, String>,
115117
},
116118
}
117119

@@ -147,12 +149,23 @@ pub struct PluginBundleStore {
147149
root: PathBuf,
148150
}
149151

152+
/// Installed VCS backend metadata resolved from a plugin module.
153+
#[derive(Debug, Clone)]
154+
pub struct ModuleVcsBackend {
155+
/// Logical backend identifier.
156+
pub id: String,
157+
/// Optional human-readable backend name.
158+
pub name: Option<String>,
159+
/// Optional action-label map keyed by namespaced VCS actions.
160+
pub action_labels: BTreeMap<String, String>,
161+
}
162+
150163
/// Installed module component metadata and resolved executable path.
151164
#[derive(Debug, Clone)]
152165
pub struct ModuleComponent {
153166
pub exec: String,
154167
pub exec_path: PathBuf,
155-
pub vcs_backends: Vec<(String, Option<String>)>,
168+
pub vcs_backends: Vec<ModuleVcsBackend>,
156169
}
157170

158171
/// Active component metadata for a plugin selected by `current.json`.
@@ -718,9 +731,17 @@ impl PluginBundleStore {
718731
.filter_map(|backend| match backend {
719732
VcsBackendProvide::Id(id) => {
720733
let id = id.trim().to_string();
721-
(!id.is_empty()).then_some((id, None))
734+
(!id.is_empty()).then_some(ModuleVcsBackend {
735+
id,
736+
name: None,
737+
action_labels: BTreeMap::new(),
738+
})
722739
}
723-
VcsBackendProvide::Named { id, name } => {
740+
VcsBackendProvide::Named {
741+
id,
742+
name,
743+
action_labels,
744+
} => {
724745
let id = id.trim().to_string();
725746
if id.is_empty() {
726747
return None;
@@ -730,7 +751,11 @@ impl PluginBundleStore {
730751
.map(str::trim)
731752
.filter(|value| !value.is_empty())
732753
.map(str::to_string);
733-
Some((id, name))
754+
Some(ModuleVcsBackend {
755+
id,
756+
name,
757+
action_labels,
758+
})
734759
}
735760
})
736761
.collect(),
@@ -872,6 +897,19 @@ mod tests {
872897
fs::write(root.join("bin").join("plugin.js"), "export {};\n").unwrap();
873898
}
874899

900+
/// Writes a prepared plugin directory with backend action labels.
901+
fn write_plugin_with_labels(root: &Path, plugin_id: &str) {
902+
fs::create_dir_all(root.join("bin")).unwrap();
903+
fs::write(
904+
root.join("package.json"),
905+
format!(
906+
"{{\n \"name\": \"{plugin_id}\",\n \"version\": \"0.1.0\",\n \"openvcs\": {{\n \"id\": \"{plugin_id}\",\n \"name\": \"Test\",\n \"version\": \"0.1.0\",\n \"module\": {{\n \"exec\": \"plugin.js\",\n \"vcs_backends\": [{{\n \"id\": \"git\",\n \"name\": \"Git\",\n \"action_labels\": {{\n \"VCS.Commit\": \"Commit\",\n \"VCS.Push\": \"Push\"\n }}\n }}]\n }}\n }}\n}}\n"
907+
),
908+
)
909+
.unwrap();
910+
fs::write(root.join("bin").join("plugin.js"), "export {};\n").unwrap();
911+
}
912+
875913
#[test]
876914
fn install_prepared_plugin_dir_writes_index_and_source() {
877915
let dir = tempdir().unwrap();
@@ -898,4 +936,41 @@ mod tests {
898936
.is_some_and(|metadata| metadata.kind == "path")
899937
);
900938
}
939+
940+
#[test]
941+
fn load_current_components_reads_backend_action_labels() {
942+
let dir = tempdir().unwrap();
943+
let store = PluginBundleStore::new_at(dir.path().join("plugins"));
944+
let prepared = dir.path().join("prepared");
945+
write_plugin_with_labels(&prepared, "example.plugin");
946+
947+
store
948+
.install_prepared_plugin_dir(
949+
&prepared,
950+
&InstalledPluginSourceMetadata {
951+
managed_by: "user-config".to_string(),
952+
kind: "path".to_string(),
953+
spec: "../example".to_string(),
954+
},
955+
true,
956+
)
957+
.unwrap();
958+
959+
let components = store.load_current_components("example.plugin").unwrap();
960+
let module = components.and_then(|c| c.module).expect("module component");
961+
let backend = module
962+
.vcs_backends
963+
.into_iter()
964+
.find(|backend| backend.id == "git")
965+
.expect("git backend");
966+
assert_eq!(backend.name.as_deref(), Some("Git"));
967+
assert_eq!(
968+
backend.action_labels.get("VCS.Commit").map(String::as_str),
969+
Some("Commit")
970+
);
971+
assert_eq!(
972+
backend.action_labels.get("VCS.Push").map(String::as_str),
973+
Some("Push")
974+
);
975+
}
901976
}

Backend/src/plugin_vcs_backends.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ use crate::plugin_runtime::settings_store;
1111
use crate::plugin_runtime::{vcs_proxy::PluginVcsProxy, PluginRuntimeManager};
1212
use crate::settings::AppConfig;
1313
use log::{debug, error, info, trace, warn};
14-
use std::{collections::BTreeMap, path::Path, sync::Arc};
14+
use std::collections::BTreeMap;
15+
use std::{path::Path, sync::Arc};
1516

1617
const MODULE: &str = "plugin_vcs_backends";
1718

@@ -48,6 +49,8 @@ pub struct PluginBackendDescriptor {
4849
pub backend_id: BackendId,
4950
/// Optional human-readable backend name.
5051
pub backend_name: Option<String>,
52+
/// Optional action-label map keyed by namespaced VCS actions.
53+
pub action_labels: BTreeMap<String, String>,
5154
/// Owning plugin identifier.
5255
pub plugin_id: String,
5356
/// Optional human-readable plugin name.
@@ -131,15 +134,16 @@ pub fn list_plugin_vcs_backends() -> Result<Vec<PluginBackendDescriptor>, String
131134
continue;
132135
};
133136

134-
for (id, name) in module.vcs_backends {
135-
let backend_id = BackendId::from(id.as_str());
137+
for backend in module.vcs_backends {
138+
let backend_id = BackendId::from(backend.id.as_str());
136139
debug!(
137140
"list_plugin_vcs_backends: found backend '{}' from plugin '{}'",
138141
backend_id, p.plugin_id
139142
);
140143
let candidate = PluginBackendDescriptor {
141144
backend_id: backend_id.clone(),
142-
backend_name: name,
145+
backend_name: backend.name,
146+
action_labels: backend.action_labels,
143147
plugin_id: p.plugin_id.clone(),
144148
plugin_name: p.name.clone(),
145149
};

Backend/src/tauri_commands/backends.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,24 @@ pub fn list_vcs_backends_cmd(state: State<'_, AppState>) -> Vec<(String, String)
6767
backends
6868
}
6969

70+
#[tauri::command]
71+
/// Returns the action-label map for the currently selected backend.
72+
///
73+
/// # Parameters
74+
/// - `state`: Shared application state.
75+
///
76+
/// # Returns
77+
/// - A list of `(action_key, label)` tuples for the active backend.
78+
pub fn current_vcs_action_labels(
79+
state: State<'_, AppState>,
80+
) -> Result<Vec<(String, String)>, String> {
81+
let repo = state
82+
.current_repo()
83+
.ok_or_else(|| "No repository selected".to_string())?;
84+
let desc = plugin_vcs_backends::plugin_vcs_backend_descriptor(&repo.id())?;
85+
Ok(desc.action_labels.into_iter().collect())
86+
}
87+
7088
#[tauri::command]
7189
/// Sets the default backend and reopens the current repository with it when possible.
7290
///

Backend/src/tauri_commands/general.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ pub async fn browse_directory<R: Runtime>(
6464
) -> Option<String> {
6565
let title = match purpose.as_deref() {
6666
Some("clone_dest") => "Choose destination folder",
67-
Some("add_repo") => "Select an existing Git repository folder",
67+
Some("add_repo") => "Select an existing repository folder",
6868
_ => "Select a folder",
6969
};
7070
utilities::browse_directory_async(window.app_handle().clone(), title).await

Frontend/src/scripts/features/repo/hydrate.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,3 +231,23 @@ export async function hydrateStash(): Promise<void> {
231231
(state as any).stash = [];
232232
}
233233
}
234+
235+
/**
236+
* Loads the resolved action-label map for the active backend.
237+
*/
238+
export async function hydrateVcsActionLabels(): Promise<void> {
239+
try {
240+
const labels = await TAURI.invoke<Array<[string, string]>>('current_vcs_action_labels');
241+
const resolved: Record<string, string> = {};
242+
for (const pair of labels || []) {
243+
if (!Array.isArray(pair) || pair.length < 2) continue;
244+
const key = String(pair[0] || '').trim();
245+
const label = String(pair[1] || '').trim();
246+
if (!key || !label) continue;
247+
resolved[key] = label;
248+
}
249+
state.vcsActionLabels = resolved;
250+
} catch {
251+
state.vcsActionLabels = {};
252+
}
253+
}

Frontend/src/scripts/features/repo/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
export { bindRepoHotkeys } from './hotkeys';
44
export { bindFilter } from './filter';
55
export { renderList, wireRenderListCallbacks } from './list';
6-
export { hydrateBranches, hydrateStatus, hydrateCommits, hydrateStash, yieldToPaint } from './hydrate';
6+
export { hydrateBranches, hydrateStatus, hydrateCommits, hydrateStash, hydrateVcsActionLabels, yieldToPaint } from './hydrate';

Frontend/src/scripts/main.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@ import { qs } from './lib/dom';
88
import { notify } from './lib/notify';
99
import { setStatus } from './lib/status';
1010
import { destroyOverlayScrollbarsFor, initOverlayScrollbarsFor, refreshOverlayScrollbarsFor } from './lib/scrollbars';
11-
import { prefs, state, hasRepo } from './state/state';
11+
import { prefs, state, hasRepo, resolveVcsActionLabel } from './state/state';
1212
import {
1313
bindTabs, initResizer, refreshRepoActions, setRepoHeader, resetRepoHeader, setTab, setTheme,
1414
bindLayoutActionState
1515
} from './ui/layout';
1616
import { clearPluginMenubarMenus, initMenubar, refreshPluginMenubarMenus } from './ui/menubar';
1717
import { closeAllModals } from './ui/modals';
1818
import { bindCommandSheet, openSheet, closeSheet } from './features/commandSheet';
19-
import { bindRepoHotkeys, bindFilter, renderList, wireRenderListCallbacks, hydrateBranches, hydrateStatus, hydrateCommits, hydrateStash, yieldToPaint } from './features/repo';
19+
import { bindRepoHotkeys, bindFilter, renderList, wireRenderListCallbacks, hydrateBranches, hydrateStatus, hydrateCommits, hydrateStash, hydrateVcsActionLabels, yieldToPaint } from './features/repo';
2020
import { bindBranchUI } from './features/branches';
2121
import { bindCommit } from './features/diff';
2222
import { openAbout } from './features/about';
@@ -188,7 +188,7 @@ async function boot() {
188188
const ctl = status ?? statusController();
189189
let success = false;
190190
try {
191-
ctl.setBusy('Fetching…');
191+
ctl.setBusy(`${resolveVcsActionLabel('VCS.Fetch', 'Fetch')}…`);
192192
await TAURI.invoke('vcs_fetch', {});
193193
notify('Fetched');
194194
if (hydrate) {
@@ -243,10 +243,12 @@ async function boot() {
243243
const behind = getBehindCount();
244244
const repoOn = hasRepo();
245245
const canPull = repoOn;
246-
const mainLabel = behind > 0 ? `Pull (${behind})` : 'Fetch';
246+
const fetchLabel = resolveVcsActionLabel('VCS.Fetch', 'Fetch');
247+
const pullLabel = resolveVcsActionLabel('VCS.Pull', 'Pull');
248+
const mainLabel = behind > 0 ? `${pullLabel} (${behind})` : fetchLabel;
247249
const mainTitle = behind > 0
248-
? `Pull ${behind} commit${behind === 1 ? '' : 's'} (F5)`
249-
: 'Fetch (F5)';
250+
? `${pullLabel} ${behind} commit${behind === 1 ? '' : 's'} (F5)`
251+
: `${fetchLabel} (F5)`;
250252

251253
if (fetchBtn) {
252254
fetchBtn.textContent = mainLabel;
@@ -262,18 +264,18 @@ async function boot() {
262264
fetchOnlyItem.setAttribute('aria-disabled', 'false');
263265
fetchOnlyItem.tabIndex = 0;
264266
const name = fetchOnlyItem.querySelector<HTMLElement>('.name');
265-
if (name) name.textContent = 'Fetch';
267+
if (name) name.textContent = fetchLabel;
266268
}
267269
if (fetchAllItem) {
268270
fetchAllItem.setAttribute('aria-disabled', 'false');
269271
fetchAllItem.tabIndex = 0;
270272
}
271273
if (pullItem) {
272-
const pullLabel = behind > 0 ? `Pull (${behind})` : 'Pull';
274+
const pullText = behind > 0 ? `${pullLabel} (${behind})` : pullLabel;
273275
pullItem.setAttribute('aria-disabled', canPull ? 'false' : 'true');
274276
pullItem.tabIndex = canPull ? 0 : -1;
275277
const name = pullItem.querySelector<HTMLElement>('.name');
276-
if (name) name.textContent = pullLabel;
278+
if (name) name.textContent = pullText;
277279
}
278280
}
279281

@@ -283,7 +285,7 @@ async function boot() {
283285
if (!fetched) { ctl.clearBusy(); return; }
284286

285287
try {
286-
ctl.setBusy('Pulling…');
288+
ctl.setBusy(`${resolveVcsActionLabel('VCS.Pull', 'Pull')}ing…`);
287289
const res = await TAURI.invoke<{ pulled: boolean; branch: string; reason?: string | null }>('vcs_pull', {});
288290
if (res?.pulled) {
289291
notify('Pulled latest changes');
@@ -296,7 +298,7 @@ async function boot() {
296298
ctl.clearBusy();
297299
}
298300

299-
await Promise.allSettled([hydrateBranches(), hydrateStatus(), hydrateCommits(), hydrateStash()]);
301+
await Promise.allSettled([hydrateBranches(), hydrateStatus(), hydrateCommits(), hydrateStash(), hydrateVcsActionLabels()]);
300302
}
301303

302304
async function defaultFetchAction() {
@@ -513,7 +515,7 @@ async function boot() {
513515

514516
await hydrateBranches();
515517
setRepoHeader(path);
516-
await Promise.allSettled([hydrateStatus(), hydrateCommits()]);
518+
await Promise.allSettled([hydrateStatus(), hydrateCommits(), hydrateVcsActionLabels()]);
517519
updateFetchUI();
518520

519521
// Broadcast app-level event so branch UI and actions can sync
@@ -569,7 +571,7 @@ async function boot() {
569571
if (doFetch) {
570572
await fetchCurrentRemoteOnly({ hydrate: false });
571573
}
572-
await Promise.allSettled([hydrateBranches(), hydrateStatus(), hydrateCommits(), hydrateStash()]);
574+
await Promise.allSettled([hydrateBranches(), hydrateStatus(), hydrateCommits(), hydrateStash(), hydrateVcsActionLabels()]);
573575
updateFetchUI();
574576
})();
575577
try {
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright © 2025-2026 OpenVCS Contributors
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
import { describe, expect, it } from 'vitest';
4+
5+
/** Provides a minimal `matchMedia` test shim used by state imports. */
6+
function createMatchMediaMock(query: string) {
7+
return { matches: false, media: query, addListener: () => {}, removeListener: () => {} };
8+
}
9+
10+
// Set matchMedia before importing modules that touch browser media APIs.
11+
(globalThis as any).matchMedia = createMatchMediaMock;
12+
13+
import { resolveVcsActionLabel, state } from './state';
14+
15+
describe('resolveVcsActionLabel', () => {
16+
it('falls back to generic VCS text when a label is missing', () => {
17+
state.vcsActionLabels = {};
18+
expect(resolveVcsActionLabel('VCS.Push', 'Push')).toBe('Push');
19+
});
20+
21+
it('returns the plugin-provided label when available', () => {
22+
state.vcsActionLabels = { 'VCS.Push': 'Ship' };
23+
expect(resolveVcsActionLabel('VCS.Push', 'Push')).toBe('Ship');
24+
});
25+
});

0 commit comments

Comments
 (0)