feat(submodules): support auth and checkout submodule revisions
Add credential-aware submodule operations and a command to checkout a specific
tag or commit in a submodule without staging the parent repository.
- Backend (src-tauri):
- Export checkout_submodule_revision and implement checkout_revision which
validates tag vs commit inputs, verifies refs locally, and checks out the
submodule in detached mode without modifying the parent's index.
- Add optional username/password parameters to add_submodule and submodule_action
flows. Implement submodule_git to call run_git_authenticated when credentials
are supplied and classify auth failures by prefixing errors with "AUTH_FAILED:".
- Wire authenticated variants (operate_authenticated, add_authenticated) and
update fetch/update actions to use credentials where needed.
- Add unit tests covering authenticated submodule commands, auth failure
classification, and checkout-by-tag/commit behavior.
- Frontend:
- App.svelte: introduce credential prompt flow (withSubmoduleCredentials,
submit/cancel handlers), surface credential dialog on auth failures, and
wire credentialed calls for initialize/add/update/fetch operations. Hook up
checkoutSubmoduleRevision and listTags to the submodule dialog.
- SubmoduleDialog.svelte: add UI for selecting destination folder, loading
tags and checking out revisions; expose fetch action.
- CredentialDialog.svelte: include "submodule" action and adjust labels.
- Docs:
- README: document "Change commit or tag" and "Fetch tags & commits" behaviors.
The commit focuses only on enabling credentialed submodule interactions and
safe local checkouts of tags/commits; no other git behavior changes are made.
This commit is contained in:
@@ -39,6 +39,9 @@ shows each submodule's recorded commit (from its parent's index), checked-out
|
|||||||
commit, and local changes. You can add a submodule, initialize it, check out its
|
commit, and local changes. You can add a submodule, initialize it, check out its
|
||||||
recorded commit, stage a changed reference, synchronize its URL from `.gitmodules`,
|
recorded commit, stage a changed reference, synchronize its URL from `.gitmodules`,
|
||||||
or open it as a repository tab. Nested submodules are included by default.
|
or open it as a repository tab. Nested submodules are included by default.
|
||||||
|
Use **Change commit or tag** to select a local tag or enter a commit hash;
|
||||||
|
**Fetch tags & commits** downloads remote revisions using the submodule login.
|
||||||
|
Checking out a revision leaves the parent index unchanged until you stage its reference.
|
||||||
|
|
||||||
Checking out a recorded commit is blocked when the submodule has local changes;
|
Checking out a recorded commit is blocked when the submodule has local changes;
|
||||||
commit or stash them in that repository first. Adding a submodule stages
|
commit or stash them in that repository first. Adding a submodule stages
|
||||||
|
|||||||
@@ -185,7 +185,19 @@ pub async fn list_submodules(path: String, recursive: bool) -> Result<Vec<GitSub
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
fn operate(repo: &Path, module_path: &str, action: &str, recursive: bool) -> Result<(), String> {
|
fn operate(repo: &Path, module_path: &str, action: &str, recursive: bool) -> Result<(), String> {
|
||||||
|
operate_authenticated(repo, module_path, action, recursive, None, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operate_authenticated(
|
||||||
|
repo: &Path,
|
||||||
|
module_path: &str,
|
||||||
|
action: &str,
|
||||||
|
recursive: bool,
|
||||||
|
username: Option<&str>,
|
||||||
|
password: Option<&str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
let module = list(repo, true)?
|
let module = list(repo, true)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|m| m.path == module_path)
|
.find(|m| m.path == module_path)
|
||||||
@@ -214,7 +226,18 @@ fn operate(repo: &Path, module_path: &str, action: &str, recursive: bool) -> Res
|
|||||||
args.push("--recursive");
|
args.push("--recursive");
|
||||||
}
|
}
|
||||||
args.extend(["--", module.relative_path.as_str()]);
|
args.extend(["--", module.relative_path.as_str()]);
|
||||||
submodule_git(owner, &args)?;
|
submodule_git(owner, &args, username, password)?;
|
||||||
|
}
|
||||||
|
"fetch" => {
|
||||||
|
if module.local_commit.is_none() {
|
||||||
|
return Err("Initialize the submodule first.".into());
|
||||||
|
}
|
||||||
|
submodule_git(
|
||||||
|
Path::new(&module.full_path),
|
||||||
|
&["fetch", "--tags"],
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
"stage" => {
|
"stage" => {
|
||||||
if module.local_commit.is_none() {
|
if module.local_commit.is_none() {
|
||||||
@@ -231,7 +254,7 @@ fn operate(repo: &Path, module_path: &str, action: &str, recursive: bool) -> Res
|
|||||||
args.push("--recursive");
|
args.push("--recursive");
|
||||||
}
|
}
|
||||||
args.extend(["--", module.relative_path.as_str()]);
|
args.extend(["--", module.relative_path.as_str()]);
|
||||||
submodule_git(owner, &args)?;
|
submodule_git(owner, &args, username, password)?;
|
||||||
}
|
}
|
||||||
_ => return Err("Unknown submodule action.".into()),
|
_ => return Err("Unknown submodule action.".into()),
|
||||||
}
|
}
|
||||||
@@ -244,14 +267,103 @@ pub async fn submodule_action(
|
|||||||
module_path: String,
|
module_path: String,
|
||||||
action: String,
|
action: String,
|
||||||
recursive: bool,
|
recursive: bool,
|
||||||
|
username: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
run_git_task("Could not update submodule", move || {
|
run_git_task("Could not update submodule", move || {
|
||||||
operate(&resolve_repo(&path)?, &module_path, &action, recursive)
|
operate_authenticated(
|
||||||
|
&resolve_repo(&path)?,
|
||||||
|
&module_path,
|
||||||
|
&action,
|
||||||
|
recursive,
|
||||||
|
username.as_deref(),
|
||||||
|
password.as_deref(),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn checkout_revision(
|
||||||
|
repo: &Path,
|
||||||
|
module_path: &str,
|
||||||
|
revision: &str,
|
||||||
|
kind: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let module = list(repo, true)?
|
||||||
|
.into_iter()
|
||||||
|
.find(|m| m.path == module_path)
|
||||||
|
.ok_or("Submodule no longer exists. Refresh the list.")?;
|
||||||
|
if module.local_commit.is_none() {
|
||||||
|
return Err("Initialize the submodule first.".into());
|
||||||
|
}
|
||||||
|
if module.dirty || module.conflicted {
|
||||||
|
return Err("Commit or stash local changes and resolve conflicts before changing the submodule revision.".into());
|
||||||
|
}
|
||||||
|
let target = Path::new(&module.full_path);
|
||||||
|
let revision = revision.trim();
|
||||||
|
let reference = match kind {
|
||||||
|
"commit"
|
||||||
|
if (4..=64).contains(&revision.len())
|
||||||
|
&& revision.bytes().all(|c| c.is_ascii_hexdigit()) =>
|
||||||
|
{
|
||||||
|
revision.to_owned()
|
||||||
|
}
|
||||||
|
"tag" => {
|
||||||
|
let reference = format!("refs/tags/{revision}");
|
||||||
|
run_git(target, ["check-ref-format", &reference])?;
|
||||||
|
reference
|
||||||
|
}
|
||||||
|
_ => return Err("Choose a tag or enter a valid commit hash.".into()),
|
||||||
|
};
|
||||||
|
let hash = run_git(
|
||||||
|
target,
|
||||||
|
[
|
||||||
|
"rev-parse",
|
||||||
|
"--verify",
|
||||||
|
"--end-of-options",
|
||||||
|
&format!("{reference}^{{commit}}"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|_| "Commit or tag was not found locally. Fetch tags and commits first.".to_owned())?;
|
||||||
|
let hash = String::from_utf8_lossy(&hash);
|
||||||
|
run_git(
|
||||||
|
target,
|
||||||
|
[
|
||||||
|
"checkout",
|
||||||
|
"--detach",
|
||||||
|
"--no-recurse-submodules",
|
||||||
|
hash.trim(),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn checkout_submodule_revision(
|
||||||
|
path: String,
|
||||||
|
module_path: String,
|
||||||
|
revision: String,
|
||||||
|
kind: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
run_git_task("Could not change submodule revision", move || {
|
||||||
|
checkout_revision(&resolve_repo(&path)?, &module_path, &revision, &kind)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
fn add(repo: &Path, url: &str, destination: &str, branch: Option<&str>) -> Result<(), String> {
|
fn add(repo: &Path, url: &str, destination: &str, branch: Option<&str>) -> Result<(), String> {
|
||||||
|
add_authenticated(repo, url, destination, branch, None, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_authenticated(
|
||||||
|
repo: &Path,
|
||||||
|
url: &str,
|
||||||
|
destination: &str,
|
||||||
|
branch: Option<&str>,
|
||||||
|
username: Option<&str>,
|
||||||
|
password: Option<&str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
safe_path(repo, destination)?;
|
safe_path(repo, destination)?;
|
||||||
if url.trim().is_empty() || url.starts_with('-') {
|
if url.trim().is_empty() || url.starts_with('-') {
|
||||||
return Err("Enter a valid repository URL.".into());
|
return Err("Enter a valid repository URL.".into());
|
||||||
@@ -262,11 +374,19 @@ fn add(repo: &Path, url: &str, destination: &str, branch: Option<&str>) -> Resul
|
|||||||
args.extend(["--branch", branch]);
|
args.extend(["--branch", branch]);
|
||||||
}
|
}
|
||||||
args.extend(["--", url, destination]);
|
args.extend(["--", url, destination]);
|
||||||
submodule_git(repo, &args)?;
|
submodule_git(repo, &args, username, password)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn submodule_git(repo: &Path, args: &[&str]) -> Result<(), String> {
|
fn submodule_git(
|
||||||
|
repo: &Path,
|
||||||
|
args: &[&str],
|
||||||
|
username: Option<&str>,
|
||||||
|
password: Option<&str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if let (Some(username), Some(password)) = (username, password) {
|
||||||
|
return super::run_git_authenticated(repo, args, username, password).map(|_| ());
|
||||||
|
}
|
||||||
let output = super::git_command()
|
let output = super::git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(repo)
|
.arg(repo)
|
||||||
@@ -277,7 +397,12 @@ fn submodule_git(repo: &Path, args: &[&str]) -> Result<(), String> {
|
|||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(super::command_output_details(&output))
|
let details = super::command_output_details(&output);
|
||||||
|
if super::is_auth_error(&details) {
|
||||||
|
Err(format!("AUTH_FAILED:{details}"))
|
||||||
|
} else {
|
||||||
|
Err(details)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,9 +412,18 @@ pub async fn add_submodule(
|
|||||||
url: String,
|
url: String,
|
||||||
destination: String,
|
destination: String,
|
||||||
branch: Option<String>,
|
branch: Option<String>,
|
||||||
|
username: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
run_git_task("Could not add submodule", move || {
|
run_git_task("Could not add submodule", move || {
|
||||||
add(&resolve_repo(&path)?, &url, &destination, branch.as_deref())
|
add_authenticated(
|
||||||
|
&resolve_repo(&path)?,
|
||||||
|
&url,
|
||||||
|
&destination,
|
||||||
|
branch.as_deref(),
|
||||||
|
username.as_deref(),
|
||||||
|
password.as_deref(),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -345,6 +479,123 @@ mod tests {
|
|||||||
git(&parent, &["commit", "-qam", "submodule"]);
|
git(&parent, &["commit", "-qam", "submodule"]);
|
||||||
Fixture(path)
|
Fixture(path)
|
||||||
}
|
}
|
||||||
|
#[test]
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn submodules_authenticated_commands_receive_askpass_credentials() {
|
||||||
|
let f = fixture();
|
||||||
|
let repo = f.0.join("parent");
|
||||||
|
let probe = r#"alias.auth-probe=!test "$("$GIT_ASKPASS" Username)" = 'fixture-user' && test "$("$GIT_ASKPASS" Password)" = 'fixture-token'"#;
|
||||||
|
submodule_git(
|
||||||
|
&repo,
|
||||||
|
&["-c", probe, "auth-probe"],
|
||||||
|
Some("fixture-user"),
|
||||||
|
Some("fixture-token"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn submodules_auth_failures_are_classified_for_the_login_dialog() {
|
||||||
|
let f = fixture();
|
||||||
|
let repo = f.0.join("parent");
|
||||||
|
let probe = "alias.auth-probe=!echo 'fatal: could not read Username: terminal prompts disabled' >&2; exit 1";
|
||||||
|
let error = submodule_git(&repo, &["-c", probe, "auth-probe"], None, None).unwrap_err();
|
||||||
|
assert!(error.starts_with("AUTH_FAILED:"));
|
||||||
|
let error = submodule_git(
|
||||||
|
&repo,
|
||||||
|
&["-c", probe, "auth-probe"],
|
||||||
|
Some("user"),
|
||||||
|
Some("token"),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.starts_with("AUTH_FAILED:"));
|
||||||
|
let error = submodule_git(&repo, &["not-a-command"], None, None).unwrap_err();
|
||||||
|
assert!(!error.starts_with("AUTH_FAILED:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn submodules_checkout_tags_and_commits_without_staging_parent() {
|
||||||
|
let f = fixture();
|
||||||
|
let repo = f.0.join("parent");
|
||||||
|
let child = repo.join("libs/with spaces");
|
||||||
|
let original = list(&repo, true).unwrap()[0].recorded_commit.clone();
|
||||||
|
git(&child, &["tag", "v1.0"]);
|
||||||
|
fs::write(child.join("file.txt"), "version two\n").unwrap();
|
||||||
|
git(
|
||||||
|
&child,
|
||||||
|
&[
|
||||||
|
"-c",
|
||||||
|
"user.name=Test",
|
||||||
|
"-c",
|
||||||
|
"user.email=test@example.com",
|
||||||
|
"commit",
|
||||||
|
"-qam",
|
||||||
|
"version two",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
git(
|
||||||
|
&child,
|
||||||
|
&[
|
||||||
|
"-c",
|
||||||
|
"user.name=Test",
|
||||||
|
"-c",
|
||||||
|
"user.email=test@example.com",
|
||||||
|
"tag",
|
||||||
|
"-a",
|
||||||
|
"v2.0",
|
||||||
|
"-m",
|
||||||
|
"version two",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let latest = list(&repo, true).unwrap()[0].local_commit.clone().unwrap();
|
||||||
|
checkout_revision(&repo, "libs/with spaces", "v1.0", "tag").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
list(&repo, true).unwrap()[0].local_commit.as_deref(),
|
||||||
|
Some(original.as_str())
|
||||||
|
);
|
||||||
|
checkout_revision(&repo, "libs/with spaces", "v2.0", "tag").unwrap();
|
||||||
|
let module = list(&repo, true).unwrap().remove(0);
|
||||||
|
assert_eq!(module.local_commit.as_deref(), Some(latest.as_str()));
|
||||||
|
assert_eq!(module.recorded_commit, original);
|
||||||
|
assert!(module.branch.is_none());
|
||||||
|
checkout_revision(&repo, "libs/with spaces", &original[..8], "commit").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
list(&repo, true).unwrap()[0].local_commit.as_deref(),
|
||||||
|
Some(original.as_str())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn submodules_revision_rejects_unknown_refs_options_and_local_changes() {
|
||||||
|
let f = fixture();
|
||||||
|
let repo = f.0.join("parent");
|
||||||
|
let child = repo.join("libs/with spaces");
|
||||||
|
let before = list(&repo, true).unwrap()[0].local_commit.clone();
|
||||||
|
for (revision, kind) in [
|
||||||
|
("--force", "commit"),
|
||||||
|
("HEAD~1", "commit"),
|
||||||
|
("../bad", "tag"),
|
||||||
|
("missing", "tag"),
|
||||||
|
("deadbeef", "commit"),
|
||||||
|
("main", "branch"),
|
||||||
|
] {
|
||||||
|
assert!(checkout_revision(&repo, "libs/with spaces", revision, kind).is_err());
|
||||||
|
}
|
||||||
|
assert_eq!(list(&repo, true).unwrap()[0].local_commit, before);
|
||||||
|
git(&child, &["tag", "valid"]);
|
||||||
|
fs::write(child.join("file.txt"), "local changes\n").unwrap();
|
||||||
|
assert!(
|
||||||
|
checkout_revision(&repo, "libs/with spaces", "valid", "tag")
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("stash")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(child.join("file.txt")).unwrap(),
|
||||||
|
"local changes\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn submodules_list_clean_and_uninitialized() {
|
fn submodules_list_clean_and_uninitialized() {
|
||||||
let f = fixture();
|
let f = fixture();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use badge::set_sync_badge;
|
|||||||
use external_tools::{
|
use external_tools::{
|
||||||
detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool,
|
detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool,
|
||||||
};
|
};
|
||||||
use git::submodules::{add_submodule, list_submodules, submodule_action};
|
use git::submodules::{checkout_submodule_revision, add_submodule, list_submodules, submodule_action};
|
||||||
use git::{
|
use git::{
|
||||||
SearchCancellationState, add_remote, add_to_gitignore, add_worktree, amend_commit,
|
SearchCancellationState, add_remote, add_to_gitignore, add_worktree, amend_commit,
|
||||||
apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
|
||||||
@@ -366,6 +366,7 @@ async fn main() {
|
|||||||
list_submodules,
|
list_submodules,
|
||||||
add_submodule,
|
add_submodule,
|
||||||
submodule_action,
|
submodule_action,
|
||||||
|
checkout_submodule_revision,
|
||||||
list_worktrees,
|
list_worktrees,
|
||||||
add_worktree,
|
add_worktree,
|
||||||
remove_worktree,
|
remove_worktree,
|
||||||
|
|||||||
+64
-4
@@ -55,6 +55,7 @@
|
|||||||
listSubmodules,
|
listSubmodules,
|
||||||
addSubmodule,
|
addSubmodule,
|
||||||
submoduleAction,
|
submoduleAction,
|
||||||
|
checkoutSubmoduleRevision,
|
||||||
checkoutBranch,
|
checkoutBranch,
|
||||||
cherryPickAbort,
|
cherryPickAbort,
|
||||||
cherryPickCommit,
|
cherryPickCommit,
|
||||||
@@ -440,6 +441,9 @@
|
|||||||
let submoduleNoticeRequest = 0;
|
let submoduleNoticeRequest = 0;
|
||||||
let pendingSubmoduleInitialization: { repoPath: string; modules: GitSubmodule[] } | null = null;
|
let pendingSubmoduleInitialization: { repoPath: string; modules: GitSubmodule[] } | null = null;
|
||||||
let submoduleInitializationError = "";
|
let submoduleInitializationError = "";
|
||||||
|
let submoduleAuthRequest: { url: string; key: string | null; credential: StoredCredential | null; resolve: (credential: StoredCredential | null) => void } | null = null;
|
||||||
|
let submoduleAuthError = "";
|
||||||
|
let submoduleAuthSaving = false;
|
||||||
let submoduleDialogOpen = false;
|
let submoduleDialogOpen = false;
|
||||||
let submodules: GitSubmodule[] = [];
|
let submodules: GitSubmodule[] = [];
|
||||||
let submodulesLoading = false;
|
let submodulesLoading = false;
|
||||||
@@ -3379,6 +3383,53 @@
|
|||||||
deleteBranchForce = false;
|
deleteBranchForce = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withSubmoduleCredentials(url: string, task: (username?: string, password?: string) => Promise<void>) {
|
||||||
|
const key = orgKeyFromUrl(url);
|
||||||
|
let credential = key && !rejectedCredentialKeys.has(key) ? await loadStoredCredential(key) : null;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
await task(credential?.username, credential?.password);
|
||||||
|
if (key) rejectedCredentialKeys.delete(key);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
const message = errorToMessage(error);
|
||||||
|
if (!isAuthError(message)) throw error;
|
||||||
|
if (credential && key) rejectedCredentialKeys.add(key);
|
||||||
|
submoduleAuthError = stripAuthPrefix(message);
|
||||||
|
const previous = credential;
|
||||||
|
credential = await new Promise<StoredCredential | null>(resolve => {
|
||||||
|
submoduleAuthRequest = { url, key, credential: previous, resolve };
|
||||||
|
});
|
||||||
|
if (!credential) throw new Error(appLanguage === "de" ? "Submodule-Anmeldung abgebrochen." : "Submodule sign-in cancelled.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSubmoduleCredential(username: string, password: string, save: boolean, mode: CredentialMode) {
|
||||||
|
const request = submoduleAuthRequest;
|
||||||
|
if (!request || submoduleAuthSaving) return;
|
||||||
|
submoduleAuthSaving = true;
|
||||||
|
try {
|
||||||
|
const credential = { username, password, mode };
|
||||||
|
if (save && request.key) {
|
||||||
|
await credSave(request.key, username, password, mode);
|
||||||
|
credentialCache.set(request.key, credential);
|
||||||
|
}
|
||||||
|
submoduleAuthRequest = null;
|
||||||
|
submoduleAuthError = "";
|
||||||
|
request.resolve(credential);
|
||||||
|
} catch (error) { submoduleAuthError = errorToMessage(error); }
|
||||||
|
finally { submoduleAuthSaving = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelSubmoduleCredential() {
|
||||||
|
if (submoduleAuthSaving) return;
|
||||||
|
const request = submoduleAuthRequest;
|
||||||
|
submoduleAuthRequest = null;
|
||||||
|
submoduleAuthError = "";
|
||||||
|
request?.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshSubmoduleNotice(path: string): Promise<GitSubmodule[] | null> {
|
async function refreshSubmoduleNotice(path: string): Promise<GitSubmodule[] | null> {
|
||||||
const request = ++submoduleNoticeRequest;
|
const request = ++submoduleNoticeRequest;
|
||||||
try {
|
try {
|
||||||
@@ -3405,7 +3456,7 @@
|
|||||||
submoduleInitializationError = "";
|
submoduleInitializationError = "";
|
||||||
try {
|
try {
|
||||||
for (const module of pending.modules) {
|
for (const module of pending.modules) {
|
||||||
await submoduleAction(pending.repoPath, module.path, "initialize", true);
|
await withSubmoduleCredentials(module.url, (username, password) => submoduleAction(pending.repoPath, module.path, "initialize", true, username, password));
|
||||||
}
|
}
|
||||||
pendingSubmoduleInitialization = null;
|
pendingSubmoduleInitialization = null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -6764,12 +6815,14 @@
|
|||||||
{#if submoduleDialogOpen}
|
{#if submoduleDialogOpen}
|
||||||
{#await import("./lib/components/SubmoduleDialog.svelte") then module}
|
{#await import("./lib/components/SubmoduleDialog.svelte") then module}
|
||||||
<module.default
|
<module.default
|
||||||
modules={submodules} isLoading={submodulesLoading} {isBusy} error={submoduleError}
|
repoPath={activeRepoPath} modules={submodules} isLoading={submodulesLoading} {isBusy} error={submoduleError}
|
||||||
language={appLanguage} recursive={submoduleRecursive}
|
language={appLanguage} recursive={submoduleRecursive}
|
||||||
onRecursive={(value) => { submoduleRecursive = value; void refreshSubmodules(); }}
|
onRecursive={(value) => { submoduleRecursive = value; void refreshSubmodules(); }}
|
||||||
onRefresh={refreshSubmodules} onClose={closeSubmoduleDialog} onOpen={openSubmoduleTab}
|
onRefresh={refreshSubmodules} onClose={closeSubmoduleDialog} onOpen={openSubmoduleTab}
|
||||||
onAdd={(url, destination, branch) => runSubmoduleOperation(path => addSubmodule(path, url, destination, branch))}
|
onLoadTags={(selected) => listTags(selected.full_path)}
|
||||||
onAction={(selected, action) => runSubmoduleOperation(path => submoduleAction(path, selected.path, action, submoduleRecursive))}
|
onCheckout={(selected, revision, kind) => runSubmoduleOperation(path => checkoutSubmoduleRevision(path, selected.path, revision, kind))}
|
||||||
|
onAdd={(url, destination, branch) => runSubmoduleOperation(path => withSubmoduleCredentials(url, (username, password) => addSubmodule(path, url, destination, branch, username, password)))}
|
||||||
|
onAction={(selected, action) => runSubmoduleOperation(path => (action === "update" || action === "fetch") ? withSubmoduleCredentials(selected.url, (username, password) => submoduleAction(path, selected.path, action, submoduleRecursive, username, password)) : submoduleAction(path, selected.path, action, submoduleRecursive))}
|
||||||
/>
|
/>
|
||||||
{/await}
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -6780,3 +6833,10 @@
|
|||||||
error={submoduleInitializationError} onInitialize={initializePendingSubmodules} onLater={dismissSubmoduleInitialization} />
|
error={submoduleInitializationError} onInitialize={initializePendingSubmodules} onLater={dismissSubmoduleInitialization} />
|
||||||
{/await}
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if submoduleAuthRequest}
|
||||||
|
<CredentialDialog action="submodule" error={submoduleAuthError} isBusy={submoduleAuthSaving}
|
||||||
|
initialUsername={submoduleAuthRequest.credential?.username ?? ""}
|
||||||
|
initialMode={submoduleAuthRequest.credential ? credentialModeFor(submoduleAuthRequest.credential) : "credentials"}
|
||||||
|
onSubmit={submitSubmoduleCredential} onCancel={cancelSubmoduleCredential} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
action: "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
|
action: "push" | "pull" | "fetch" | "clone" | "rename" | "delete" | "submodule";
|
||||||
error: string;
|
error: string;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
initialUsername?: string;
|
initialUsername?: string;
|
||||||
@@ -47,9 +47,11 @@
|
|||||||
password.trim().length > 0 &&
|
password.trim().length > 0 &&
|
||||||
username.trim().length > 0,
|
username.trim().length > 0,
|
||||||
);
|
);
|
||||||
let actionLabel = $derived(action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : action === "rename" ? "Rename" : action === "delete" ? "Delete" : "Pull");
|
let actionLabel = $derived(action === "submodule" ? "Submodule" : action === "push" ? "Push" : action === "fetch" ? "Fetch" : action === "clone" ? "Clone" : action === "rename" ? "Rename" : action === "delete" ? "Delete" : "Pull");
|
||||||
let actionTitle = $derived(
|
let actionTitle = $derived(
|
||||||
action === "push"
|
action === "submodule"
|
||||||
|
? "Authenticate submodule"
|
||||||
|
: action === "push"
|
||||||
? "Authenticate push"
|
? "Authenticate push"
|
||||||
: action === "rename"
|
: action === "rename"
|
||||||
? "Authenticate remote rename"
|
? "Authenticate remote rename"
|
||||||
|
|||||||
@@ -1,22 +1,77 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Boxes, Plus, RefreshCw, X, ExternalLink, GitCommitHorizontal, LoaderCircle } from "@lucide/svelte";
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
import type { GitSubmodule } from "../types";
|
import { FolderOpen, Boxes, Plus, RefreshCw, X, ExternalLink, GitCommitHorizontal, LoaderCircle } from "@lucide/svelte";
|
||||||
|
import type { GitSubmodule, GitTag } from "../types";
|
||||||
interface Props {
|
interface Props {
|
||||||
|
repoPath: string;
|
||||||
modules: GitSubmodule[]; isLoading: boolean; isBusy: boolean; error: string;
|
modules: GitSubmodule[]; isLoading: boolean; isBusy: boolean; error: string;
|
||||||
language: "de" | "en"; recursive: boolean;
|
language: "de" | "en"; recursive: boolean;
|
||||||
onRecursive: (value: boolean) => void;
|
onRecursive: (value: boolean) => void;
|
||||||
onRefresh: () => void; onClose: () => void;
|
onRefresh: () => void; onClose: () => void;
|
||||||
onAdd: (url: string, path: string, branch: string) => Promise<boolean>;
|
onAdd: (url: string, path: string, branch: string) => Promise<boolean>;
|
||||||
onAction: (module: GitSubmodule, action: "update" | "stage" | "sync") => Promise<boolean>;
|
onAction: (module: GitSubmodule, action: "update" | "stage" | "sync" | "fetch") => Promise<boolean>;
|
||||||
|
onLoadTags: (module: GitSubmodule) => Promise<GitTag[]>;
|
||||||
|
onCheckout: (module: GitSubmodule, revision: string, kind: "tag" | "commit") => Promise<boolean>;
|
||||||
onOpen: (module: GitSubmodule) => void;
|
onOpen: (module: GitSubmodule) => void;
|
||||||
}
|
}
|
||||||
let { modules, isLoading, isBusy, error, language, recursive, onRecursive, onRefresh, onClose, onAdd, onAction, onOpen }: Props = $props();
|
let { repoPath, modules, isLoading, isBusy, error, language, recursive, onRecursive, onRefresh, onClose, onAdd, onAction, onOpen, onLoadTags, onCheckout }: Props = $props();
|
||||||
const t = (de: string, en: string) => language === "de" ? de : en;
|
const t = (de: string, en: string) => language === "de" ? de : en;
|
||||||
let selectedPath = $state("");
|
let selectedPath = $state("");
|
||||||
let adding = $state(false);
|
let adding = $state(false);
|
||||||
let url = $state("");
|
let url = $state("");
|
||||||
let destination = $state("");
|
let parentFolder = $state("");
|
||||||
|
let folderName = $state("");
|
||||||
|
let folderNameEdited = $state(false);
|
||||||
|
let browseError = $state("");
|
||||||
|
let browsing = $state(false);
|
||||||
|
const suggestedName = $derived((url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "").split(/[\\/:]/).filter(Boolean).pop() ?? "").replace(/\.git$/i, ""));
|
||||||
|
const effectiveName = $derived(folderNameEdited ? folderName.trim() : suggestedName);
|
||||||
|
const relativeParent = $derived(parentFolder.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, ""));
|
||||||
|
const destination = $derived([relativeParent === "." ? "" : relativeParent, effectiveName].filter(Boolean).join("/"));
|
||||||
|
const validDestination = $derived(Boolean(effectiveName) && !/[\\/:<>"|?*\x00-\x1f]/.test(effectiveName) && ![".", ".."].includes(effectiveName) && !effectiveName.startsWith("-") && !relativeParent.startsWith("/") && !relativeParent.includes(":") && relativeParent.split("/").every(part => part !== ".." && !part.startsWith("-")));
|
||||||
|
async function chooseSubmoduleFolder() {
|
||||||
|
browseError = ""; browsing = true;
|
||||||
|
try {
|
||||||
|
const chosen = await openDialog({ title: t("Zielordner im Repository auswählen", "Select destination inside repository"), directory: true, multiple: false, defaultPath: repoPath });
|
||||||
|
if (typeof chosen !== "string") return;
|
||||||
|
const root = repoPath.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
|
const folder = chosen.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
|
const windows = /^[a-z]:/i.test(root) || root.startsWith("//");
|
||||||
|
const compareRoot = windows ? root.toLowerCase() : root;
|
||||||
|
const compareFolder = windows ? folder.toLowerCase() : folder;
|
||||||
|
if (compareFolder !== compareRoot && !compareFolder.startsWith(compareRoot + "/")) {
|
||||||
|
browseError = t("Bitte einen Ordner innerhalb des Hauptrepositorys auswählen.", "Choose a folder inside the parent repository.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parentFolder = folder.slice(root.length).replace(/^\//, "");
|
||||||
|
} catch (error) { browseError = String(error); }
|
||||||
|
finally { browsing = false; }
|
||||||
|
}
|
||||||
let branch = $state("");
|
let branch = $state("");
|
||||||
|
let revisionKind = $state<"tag" | "commit">("tag");
|
||||||
|
let revision = $state("");
|
||||||
|
let tags = $state<GitTag[]>([]);
|
||||||
|
let tagsLoading = $state(false);
|
||||||
|
let tagsError = $state("");
|
||||||
|
let tagRequest = 0;
|
||||||
|
async function loadTags(module: GitSubmodule) {
|
||||||
|
const request = ++tagRequest;
|
||||||
|
tagsLoading = true; tagsError = "";
|
||||||
|
try { const loaded = await onLoadTags(module); if (request === tagRequest) tags = loaded; }
|
||||||
|
catch (error) { if (request === tagRequest) tagsError = String(error); }
|
||||||
|
finally { if (request === tagRequest) tagsLoading = false; }
|
||||||
|
}
|
||||||
|
$effect(() => {
|
||||||
|
const module = selected;
|
||||||
|
revision = ""; tags = []; tagsError = "";
|
||||||
|
if (module?.local_commit) void loadTags(module);
|
||||||
|
else { tagRequest++; tagsLoading = false; }
|
||||||
|
return () => { tagRequest++; };
|
||||||
|
});
|
||||||
|
async function fetchRevisions() {
|
||||||
|
const module = selected;
|
||||||
|
if (module && await onAction(module, "fetch")) await loadTags(module);
|
||||||
|
}
|
||||||
let selected = $derived(modules.find(m => m.path === selectedPath) ?? modules[0]);
|
let selected = $derived(modules.find(m => m.path === selectedPath) ?? modules[0]);
|
||||||
let disabled = $derived(isBusy || isLoading);
|
let disabled = $derived(isBusy || isLoading);
|
||||||
function statusLabel(m: GitSubmodule) {
|
function statusLabel(m: GitSubmodule) {
|
||||||
@@ -31,7 +86,7 @@
|
|||||||
(node.querySelector<HTMLElement>("button:not(:disabled)") ?? node).focus();
|
(node.querySelector<HTMLElement>("button:not(:disabled)") ?? node).focus();
|
||||||
const trap = (event: KeyboardEvent) => {
|
const trap = (event: KeyboardEvent) => {
|
||||||
if (event.key !== "Tab") return;
|
if (event.key !== "Tab") return;
|
||||||
const controls = Array.from(node.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), [tabindex="0"]'));
|
const controls = Array.from(node.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), [tabindex="0"]'));
|
||||||
const first = controls[0]; const last = controls[controls.length - 1];
|
const first = controls[0]; const last = controls[controls.length - 1];
|
||||||
if (!first) { event.preventDefault(); return; }
|
if (!first) { event.preventDefault(); return; }
|
||||||
if (event.shiftKey && (document.activeElement === first || document.activeElement === node)) { event.preventDefault(); last.focus(); }
|
if (event.shiftKey && (document.activeElement === first || document.activeElement === node)) { event.preventDefault(); last.focus(); }
|
||||||
@@ -42,8 +97,9 @@
|
|||||||
}
|
}
|
||||||
async function submit(event: SubmitEvent) {
|
async function submit(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (await onAdd(url.trim(), destination.trim(), branch.trim())) {
|
if (!validDestination || browsing) return;
|
||||||
adding = false; url = ""; destination = ""; branch = "";
|
if (await onAdd(url.trim(), destination, branch.trim())) {
|
||||||
|
adding = false; url = ""; parentFolder = ""; folderName = ""; folderNameEdited = false; branch = ""; browseError = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -67,12 +123,17 @@
|
|||||||
<form class="submodule-add" onsubmit={submit}>
|
<form class="submodule-add" onsubmit={submit}>
|
||||||
<h3>{t("Submodul hinzufügen", "Add submodule")}</h3>
|
<h3>{t("Submodul hinzufügen", "Add submodule")}</h3>
|
||||||
<label>{t("Repository-URL", "Repository URL")}<input bind:value={url} required disabled={disabled} placeholder="https://github.com/team/repository.git" /></label>
|
<label>{t("Repository-URL", "Repository URL")}<input bind:value={url} required disabled={disabled} placeholder="https://github.com/team/repository.git" /></label>
|
||||||
|
<label for="submodule-parent">{t("Zielordner · relativ zum Repository", "Destination folder · relative to repository")}</label>
|
||||||
|
<div class="submodule-folder-picker"><input id="submodule-parent" bind:value={parentFolder} disabled={disabled || browsing} placeholder={t(". (Repository-Hauptordner) oder libs", ". (repository root) or libs")} /><button class="btn-secondary" type="button" disabled={disabled || browsing} onclick={chooseSubmoduleFolder}><FolderOpen size={15} />{t("Durchsuchen", "Browse")}</button></div>
|
||||||
|
{#if browseError}<div class="submodule-error" role="alert">{browseError}</div>{/if}
|
||||||
<div class="submodule-fields">
|
<div class="submodule-fields">
|
||||||
<label>{t("Pfad im Repository", "Path in repository")}<input bind:value={destination} required disabled={disabled} placeholder="libs/repository" /></label>
|
<label>{t("Ordnername", "Folder name")}<input value={folderNameEdited ? folderName : suggestedName} oninput={event => { folderName = event.currentTarget.value; folderNameEdited = Boolean(folderName.trim()) && folderName !== suggestedName; }} disabled={disabled} placeholder={t("Wird aus der Repository-URL übernommen", "Taken from the repository URL")} /></label>
|
||||||
<label>{t("Tracking-Branch · optional", "Tracking branch · optional")}<input bind:value={branch} disabled={disabled} placeholder={t("Standard-Branch", "Default branch")} /></label>
|
<label>{t("Tracking-Branch · optional", "Tracking branch · optional")}<input bind:value={branch} disabled={disabled} placeholder={t("Standard-Branch", "Default branch")} /></label>
|
||||||
</div>
|
</div>
|
||||||
|
<p>{t("Zielpfad", "Destination")}: <code>{destination || "—"}</code></p>
|
||||||
|
{#if destination && !validDestination}<p class="submodule-error" role="alert">{t("Bitte einen relativen Zielordner und einen gültigen Ordnernamen eingeben.", "Enter a relative destination folder and a valid folder name.")}</p>{/if}
|
||||||
<p>{t("Die .gitmodules-Datei und der neue Verweis werden zum Commit vorgemerkt.", "The .gitmodules file and new reference will be staged for commit.")}</p>
|
<p>{t("Die .gitmodules-Datei und der neue Verweis werden zum Commit vorgemerkt.", "The .gitmodules file and new reference will be staged for commit.")}</p>
|
||||||
<div class="submodule-actions"><button class="btn-secondary" type="button" disabled={disabled} onclick={() => adding = false}>{t("Abbrechen", "Cancel")}</button><button class="btn-primary" disabled={disabled || !url.trim() || !destination.trim()}>{t("Submodul hinzufügen", "Add submodule")}</button></div>
|
<div class="submodule-actions"><button class="btn-secondary" type="button" disabled={disabled} onclick={() => adding = false}>{t("Abbrechen", "Cancel")}</button><button class="btn-primary" disabled={disabled || browsing || !url.trim() || !validDestination}>{t("Submodul hinzufügen", "Add submodule")}</button></div>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
{#if isLoading && !modules.length}
|
{#if isLoading && !modules.length}
|
||||||
@@ -100,6 +161,20 @@
|
|||||||
{:else if selected.local_commit !== selected.recorded_commit}{t("Der lokale Commit weicht vom gespeicherten Verweis ab. Checke den gespeicherten Stand aus oder stage den lokalen Verweis im übergeordneten Repository.", "The local commit differs from the recorded reference. Check out the recorded commit or stage the local reference in the parent repository.")}
|
{:else if selected.local_commit !== selected.recorded_commit}{t("Der lokale Commit weicht vom gespeicherten Verweis ab. Checke den gespeicherten Stand aus oder stage den lokalen Verweis im übergeordneten Repository.", "The local commit differs from the recorded reference. Check out the recorded commit or stage the local reference in the parent repository.")}
|
||||||
{:else}{t("Der lokale Stand entspricht dem gespeicherten Commit.", "The local state matches the recorded commit.")}{/if}
|
{:else}{t("Der lokale Stand entspricht dem gespeicherten Commit.", "The local state matches the recorded commit.")}{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if selected.local_commit}
|
||||||
|
<form class="submodule-revision" onsubmit={event => { event.preventDefault(); if (selected && revision.trim()) void onCheckout(selected, revision.trim(), revisionKind); }}>
|
||||||
|
<div class="revision-heading"><strong>{t("Commit oder Tag wechseln", "Change commit or tag")}</strong><button class="btn-sm" type="button" disabled={disabled || tagsLoading} onclick={fetchRevisions}><RefreshCw size={13} />{t("Tags & Commits abrufen", "Fetch tags & commits")}</button></div>
|
||||||
|
<label>{t("Auswahl", "Selection")}<select bind:value={revisionKind} onchange={() => revision = ""} disabled={disabled}><option value="tag">Tag</option><option value="commit">Commit</option></select></label>
|
||||||
|
{#if revisionKind === "tag"}
|
||||||
|
<label>Tag<select bind:value={revision} disabled={disabled || tagsLoading || !tags.length}><option value="">{tagsLoading ? t("Tags werden geladen…", "Loading tags…") : tags.length ? t("Tag auswählen", "Select tag") : t("Keine lokalen Tags", "No local tags")}</option>{#each tags as tag (tag.name)}<option value={tag.name}>{tag.name}</option>{/each}</select></label>
|
||||||
|
{:else}
|
||||||
|
<label>{t("Commit-Hash", "Commit hash")}<input bind:value={revision} disabled={disabled} placeholder="a81c2f4" spellcheck="false" required pattern={"[a-fA-F0-9]{4,64}"} /></label>
|
||||||
|
{/if}
|
||||||
|
{#if tagsError}<p class="submodule-error" role="alert">{tagsError}</p>{/if}
|
||||||
|
<button class="btn-secondary" disabled={disabled || selected.dirty || selected.conflicted || !revision.trim() || (revisionKind === "tag" && tagsLoading)}>{t("Ausgewählten Stand auschecken", "Check out selected revision")}</button>
|
||||||
|
<p>{t("Danach den neuen Verweis stagen und im übergeordneten Repository committen.", "Then stage the new reference and commit it in the parent repository.")}</p>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
<div class="submodule-actions">
|
<div class="submodule-actions">
|
||||||
<button class="btn-primary" disabled={disabled || selected.dirty || selected.conflicted} onclick={() => selected && onAction(selected, "update")}>{selected.local_commit ? t("Gespeicherten Stand auschecken", "Check out recorded commit") : t("Initialisieren", "Initialize")}</button>
|
<button class="btn-primary" disabled={disabled || selected.dirty || selected.conflicted} onclick={() => selected && onAction(selected, "update")}>{selected.local_commit ? t("Gespeicherten Stand auschecken", "Check out recorded commit") : t("Initialisieren", "Initialize")}</button>
|
||||||
{#if selected.local_commit && selected.local_commit !== selected.recorded_commit}<button class="btn-secondary" disabled={disabled || selected.conflicted} onclick={() => selected && onAction(selected, "stage")}>{t("Verweis stagen", "Stage reference")}</button>{/if}
|
{#if selected.local_commit && selected.local_commit !== selected.recorded_commit}<button class="btn-secondary" disabled={disabled || selected.conflicted} onclick={() => selected && onAction(selected, "stage")}>{t("Verweis stagen", "Stage reference")}</button>{/if}
|
||||||
@@ -135,6 +210,11 @@
|
|||||||
dd { margin: 0; display: flex; align-items: center; gap: 9px; flex-wrap: wrap; font-size: 12px; }
|
dd { margin: 0; display: flex; align-items: center; gap: 9px; flex-wrap: wrap; font-size: 12px; }
|
||||||
dd span { margin-left: auto; color: var(--color-ink-muted); }
|
dd span { margin-left: auto; color: var(--color-ink-muted); }
|
||||||
.submodule-notice { padding: 12px; margin: 20px 0; background: var(--color-surface); border-radius: 6px; font-size: 12px; line-height: 1.6; color: var(--color-ink-muted); }
|
.submodule-notice { padding: 12px; margin: 20px 0; background: var(--color-surface); border-radius: 6px; font-size: 12px; line-height: 1.6; color: var(--color-ink-muted); }
|
||||||
|
.submodule-revision { border-top: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border); padding: 16px 0; margin-bottom: 16px; }
|
||||||
|
.revision-heading { display: flex; justify-content: space-between; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.submodule-revision label { display: flex; flex-direction: column; gap: 6px; margin: 12px 0; font-size: 12px; }
|
||||||
|
.submodule-revision input, .submodule-revision select { width: 100%; min-width: 0; }
|
||||||
|
.submodule-revision p { font-size: 12px; color: var(--color-ink-muted); margin: 10px 0 0; line-height: 1.5; }
|
||||||
.submodule-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
.submodule-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
.submodule-actions button { white-space: normal; }
|
.submodule-actions button { white-space: normal; }
|
||||||
.submodule-footer { border-top: 1px solid var(--color-border); padding: 14px 20px; font-size: 11px; line-height: 1.5; color: var(--color-ink-muted); }
|
.submodule-footer { border-top: 1px solid var(--color-border); padding: 14px 20px; font-size: 11px; line-height: 1.5; color: var(--color-ink-muted); }
|
||||||
@@ -144,6 +224,9 @@
|
|||||||
.submodule-add label { display: flex; flex-direction: column; gap: 7px; font-size: 12px; margin: 13px 0; min-width: 0; }
|
.submodule-add label { display: flex; flex-direction: column; gap: 7px; font-size: 12px; margin: 13px 0; min-width: 0; }
|
||||||
.submodule-add input { width: 100%; min-width: 0; }
|
.submodule-add input { width: 100%; min-width: 0; }
|
||||||
.submodule-add p { font-size: 12px; color: var(--color-ink-muted); margin: 4px 0 16px; }
|
.submodule-add p { font-size: 12px; color: var(--color-ink-muted); margin: 4px 0 16px; }
|
||||||
|
.submodule-folder-picker { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.submodule-folder-picker input { flex: 1; }
|
||||||
|
.submodule-folder-picker button { flex-shrink: 0; }
|
||||||
.submodule-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
.submodule-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
@media (max-width: 650px) { .submodule-workspace, .submodule-fields { grid-template-columns: 1fr; } .submodule-list { border-right: 0; } .submodule-content { padding: 12px; } }
|
@media (max-width: 650px) { .submodule-workspace, .submodule-fields { grid-template-columns: 1fr; } .submodule-list { border-right: 0; } .submodule-content { padding: 12px; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+8
-4
@@ -737,9 +737,13 @@ export function pullRequestAiGenerate(path: string, remote: string, sourceBranch
|
|||||||
export function listSubmodules(path: string, recursive = true): Promise<GitSubmodule[]> {
|
export function listSubmodules(path: string, recursive = true): Promise<GitSubmodule[]> {
|
||||||
return invoke("list_submodules", { path, recursive });
|
return invoke("list_submodules", { path, recursive });
|
||||||
}
|
}
|
||||||
export function addSubmodule(path: string, url: string, destination: string, branch?: string): Promise<void> {
|
export function addSubmodule(path: string, url: string, destination: string, branch?: string, username?: string, password?: string): Promise<void> {
|
||||||
return invoke("add_submodule", { path, url, destination, branch: branch || null });
|
return invoke("add_submodule", { path, url, destination, branch: branch || null, username: username ?? null, password: password ?? null });
|
||||||
}
|
}
|
||||||
export function submoduleAction(path: string, modulePath: string, action: "update" | "stage" | "sync" | "initialize", recursive: boolean): Promise<void> {
|
export function submoduleAction(path: string, modulePath: string, action: "update" | "stage" | "sync" | "initialize" | "fetch", recursive: boolean, username?: string, password?: string): Promise<void> {
|
||||||
return invoke("submodule_action", { path, modulePath, action, recursive });
|
return invoke("submodule_action", { path, modulePath, action, recursive, username: username ?? null, password: password ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkoutSubmoduleRevision(path: string, modulePath: string, revision: string, kind: "tag" | "commit"): Promise<void> {
|
||||||
|
return invoke("checkout_submodule_revision", { path, modulePath, revision, kind });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user