Add branch creation and file history search
Implement the ability to create new local branches directly from the application's UI. This includes a new backend command to handle branch creation with validation and a frontend form in the branch panel. Additionally, extend the global search dialog to include a "Files" tab. Users can now search for files by name or path within the repository. Selecting a file in the search results displays its full commit history, with options to diff the file at a specific commit or restore it to that version.
This commit is contained in:
@@ -281,6 +281,14 @@ pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String
|
|||||||
status_for_repo(&repo)
|
status_for_repo(&repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn create_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let branch = validate_new_branch_name(&repo, &branch)?;
|
||||||
|
run_git(&repo, ["checkout", "-b", branch.as_str()])?;
|
||||||
|
status_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
@@ -1839,6 +1847,38 @@ fn local_branch_name_for_remote(remote_branch: &str) -> Option<&str> {
|
|||||||
.filter(|local| !local.is_empty())
|
.filter(|local| !local.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_new_branch_name(repo: &Path, branch: &str) -> Result<String, String> {
|
||||||
|
let branch = branch.trim();
|
||||||
|
if branch.is_empty() {
|
||||||
|
return Err("Branch-Name darf nicht leer sein.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = git_command()
|
||||||
|
.arg("-C")
|
||||||
|
.arg(repo)
|
||||||
|
.args(["check-ref-format", "--branch", branch])
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let details = command_output_details(&output);
|
||||||
|
return Err(format!("Ungueltiger Branch-Name: {details}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let normalized = if normalized.is_empty() {
|
||||||
|
branch.to_string()
|
||||||
|
} else {
|
||||||
|
normalized
|
||||||
|
};
|
||||||
|
|
||||||
|
if ref_exists(repo, &format!("refs/heads/{normalized}"))? {
|
||||||
|
return Err(format!("Branch '{normalized}' existiert bereits."));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
fn ref_exists(repo: &Path, ref_name: &str) -> Result<bool, String> {
|
fn ref_exists(repo: &Path, ref_name: &str) -> Result<bool, String> {
|
||||||
let output = git_command()
|
let output = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
@@ -3186,6 +3226,31 @@ mod tests {
|
|||||||
assert_eq!(plan, CheckoutPlan::Local("feature/demo".to_string()));
|
assert_eq!(plan, CheckoutPlan::Local("feature/demo".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_branch_creates_and_checks_out_local_branch() {
|
||||||
|
let repo = init_temp_repo("create_branch");
|
||||||
|
commit_initial_file(&repo.path);
|
||||||
|
|
||||||
|
let status = create_branch(
|
||||||
|
repo.path.to_string_lossy().to_string(),
|
||||||
|
"feature/new-panel".to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(status.current_branch.as_deref(), Some("feature/new-panel"));
|
||||||
|
assert!(
|
||||||
|
ref_exists(&repo.path, "refs/heads/feature/new-panel").unwrap(),
|
||||||
|
"new branch should exist"
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = create_branch(
|
||||||
|
repo.path.to_string_lossy().to_string(),
|
||||||
|
"feature/new-panel".to_string(),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.contains("existiert bereits"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn restore_to_commit_resets_branch_to_selected_commit() {
|
fn restore_to_commit_resets_branch_to_selected_commit() {
|
||||||
let repo = init_temp_repo("restore_to_commit");
|
let repo = init_temp_repo("restore_to_commit");
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ mod git;
|
|||||||
|
|
||||||
use git::{
|
use git::{
|
||||||
cancel_code_search, checkout_branch, commit, compare_commits, compare_file_to_head,
|
cancel_code_search, checkout_branch, commit, compare_commits, compare_file_to_head,
|
||||||
compare_file_to_parent, cred_delete, cred_load, cred_save, diff_file_against_working_tree,
|
compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save,
|
||||||
get_remote_url, get_status, list_branches, list_commits, list_file_history,
|
diff_file_against_working_tree, get_remote_url, get_status, list_branches, list_commits,
|
||||||
list_repository_files, merge_branch, open_repository, pull, push, read_conflict,
|
list_file_history, list_repository_files, merge_branch, open_repository, pull, push,
|
||||||
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
read_conflict, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
|
||||||
restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
||||||
SearchCancellationState,
|
SearchCancellationState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ fn main() {
|
|||||||
get_status,
|
get_status,
|
||||||
list_branches,
|
list_branches,
|
||||||
checkout_branch,
|
checkout_branch,
|
||||||
|
create_branch,
|
||||||
stage_files,
|
stage_files,
|
||||||
unstage_files,
|
unstage_files,
|
||||||
restore_files,
|
restore_files,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
commit,
|
commit,
|
||||||
compareCommits,
|
compareCommits,
|
||||||
cancelCodeSearch,
|
cancelCodeSearch,
|
||||||
|
createBranch,
|
||||||
diffFileAgainstWorkingTree,
|
diffFileAgainstWorkingTree,
|
||||||
compareFileToParent,
|
compareFileToParent,
|
||||||
getStatus,
|
getStatus,
|
||||||
@@ -413,6 +414,18 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createNewBranch(branchName: string) {
|
||||||
|
const name = branchName.trim();
|
||||||
|
if (!activeRepoPath || !name) return;
|
||||||
|
await runOperation(`Creating ${name}`, async () => {
|
||||||
|
applyStatus(await createBranch(activeRepoPath, name));
|
||||||
|
await refreshBranchList(activeRepoPath);
|
||||||
|
await refreshCommitHistory(activeRepoPath);
|
||||||
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
|
await refreshFileHistory(activeRepoPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function merge(branch: GitBranchInfo) {
|
async function merge(branch: GitBranchInfo) {
|
||||||
if (!activeRepoPath || branch.current) return;
|
if (!activeRepoPath || branch.current) return;
|
||||||
await runOperation(`Merging ${branch.name}`, async () => {
|
await runOperation(`Merging ${branch.name}`, async () => {
|
||||||
@@ -719,6 +732,17 @@
|
|||||||
expandedExplorerPaths = new Set();
|
expandedExplorerPaths = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function explorerParentFolders(path: string): string[] {
|
||||||
|
const parts = normalizeExplorerPath(path).split("/").filter(Boolean);
|
||||||
|
const folders: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
for (let index = 0; index < parts.length - 1; index++) {
|
||||||
|
current = current ? `${current}/${parts[index]}` : parts[index];
|
||||||
|
folders.push(current);
|
||||||
|
}
|
||||||
|
return folders;
|
||||||
|
}
|
||||||
|
|
||||||
async function selectExplorerNode(node: ExplorerNode) {
|
async function selectExplorerNode(node: ExplorerNode) {
|
||||||
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
|
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
|
||||||
selectedExplorerPath = node.path;
|
selectedExplorerPath = node.path;
|
||||||
@@ -728,6 +752,17 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function selectFileFromSearch(file: GitRepositoryFile) {
|
||||||
|
if (!activeRepoPath) return;
|
||||||
|
selectedExplorerPath = file.path;
|
||||||
|
selectedExplorerKind = "file";
|
||||||
|
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
|
||||||
|
|
||||||
|
await runOperation(`Loading ${file.path} history`, async () => {
|
||||||
|
await refreshFileHistory(activeRepoPath, file.path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function restoreSelectedFileFromCommit(target: GitCommit) {
|
async function restoreSelectedFileFromCommit(target: GitCommit) {
|
||||||
if (!activeRepoPath || !selectedExplorerPath) return;
|
if (!activeRepoPath || !selectedExplorerPath) return;
|
||||||
const kind = selectedExplorerKind === "folder" ? "folder" : "file";
|
const kind = selectedExplorerKind === "folder" ? "folder" : "file";
|
||||||
@@ -1009,6 +1044,7 @@
|
|||||||
{isBusy}
|
{isBusy}
|
||||||
onCheckout={checkout}
|
onCheckout={checkout}
|
||||||
onMerge={merge}
|
onMerge={merge}
|
||||||
|
onCreateBranch={createNewBranch}
|
||||||
/>
|
/>
|
||||||
<ExplorerPanel
|
<ExplorerPanel
|
||||||
{repoFiles}
|
{repoFiles}
|
||||||
@@ -1133,10 +1169,16 @@
|
|||||||
isSearching={globalSearchBusy}
|
isSearching={globalSearchBusy}
|
||||||
error={globalSearchError}
|
error={globalSearchError}
|
||||||
results={globalSearchResults}
|
results={globalSearchResults}
|
||||||
|
files={repoFiles}
|
||||||
|
fileHistory={fileHistory}
|
||||||
|
selectedFilePath={selectedExplorerPath}
|
||||||
onClose={closeGlobalSearchDialog}
|
onClose={closeGlobalSearchDialog}
|
||||||
onSearch={runGlobalSearch}
|
onSearch={runGlobalSearch}
|
||||||
onCancel={cancelGlobalSearch}
|
onCancel={cancelGlobalSearch}
|
||||||
onDiff={diffSearchHit}
|
onDiff={diffSearchHit}
|
||||||
|
onSelectFile={selectFileFromSearch}
|
||||||
|
onFileHistoryDiff={diffSelectedFileFromCommit}
|
||||||
|
onFileHistoryRestore={restoreSelectedFileFromCommit}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
+305
-1
@@ -715,6 +715,88 @@
|
|||||||
|
|
||||||
/* --- Branch list --- */
|
/* --- Branch list --- */
|
||||||
|
|
||||||
|
.branch-head-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.branch-create-toggle {
|
||||||
|
width: 26px;
|
||||||
|
min-width: 26px;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border-color: rgba(65,209,255,0.2);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--color-ink-dim);
|
||||||
|
background: rgba(65,209,255,0.06);
|
||||||
|
}
|
||||||
|
.branch-create-toggle:hover:not(:disabled) {
|
||||||
|
border-color: rgba(65,209,255,0.45);
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(65,209,255,0.13);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-list { gap: 8px; }
|
||||||
|
|
||||||
|
.branch-create-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border: 1px solid rgba(65,209,255,0.22);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(90deg, rgba(65,209,255,0.08), rgba(100,108,255,0.07));
|
||||||
|
}
|
||||||
|
.branch-create-form svg { color: var(--color-accent); }
|
||||||
|
.branch-create-form input {
|
||||||
|
height: 30px;
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.branch-create-action {
|
||||||
|
width: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.branch-create-action.confirm {
|
||||||
|
border-color: rgba(78,202,118,0.34);
|
||||||
|
color: #6ee090;
|
||||||
|
background: rgba(78,202,118,0.11);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branch-group { display: grid; gap: 4px; min-width: 0; }
|
||||||
|
.branch-group + .branch-group { margin-top: 8px; }
|
||||||
|
|
||||||
|
.branch-group-toggle {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
justify-content: stretch;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-color: transparent;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.branch-group-toggle:hover:not(:disabled) {
|
||||||
|
border-color: var(--color-border-subtle);
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
}
|
||||||
|
.branch-group-toggle svg { color: var(--color-ink-faint); }
|
||||||
|
|
||||||
.branch-group-label {
|
.branch-group-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -738,6 +820,12 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.branch-empty {
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.branch-row {
|
.branch-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -1175,11 +1263,38 @@
|
|||||||
|
|
||||||
.global-search-body {
|
.global-search-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.global-search-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
background: rgba(7, 8, 16, 0.34);
|
||||||
|
}
|
||||||
|
.global-search-tab {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--color-ink-dim);
|
||||||
|
background: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.global-search-tab:hover:not(:disabled) {
|
||||||
|
border-color: rgba(65,209,255,0.22);
|
||||||
|
background: rgba(65,209,255,0.07);
|
||||||
|
}
|
||||||
|
.global-search-tab.active {
|
||||||
|
border-color: rgba(65,209,255,0.36);
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(135deg, rgba(100,108,255,0.26), rgba(65,209,255,0.1));
|
||||||
|
}
|
||||||
|
|
||||||
.global-search-form {
|
.global-search-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -1206,6 +1321,11 @@
|
|||||||
white-space: pre;
|
white-space: pre;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
.global-search-query input {
|
||||||
|
height: 40px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.global-search-options {
|
.global-search-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1342,6 +1462,185 @@
|
|||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-search-form { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.file-search-results {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.file-search-split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.95fr) minmax(320px, 0.75fr);
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.file-search-column {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 10px;
|
||||||
|
border-right: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
.file-search-list { display: grid; gap: 7px; }
|
||||||
|
.file-search-hit {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.file-search-hit:hover:not(:disabled) {
|
||||||
|
border-color: rgba(65,209,255,0.3);
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
.file-search-hit.active {
|
||||||
|
border-color: rgba(90,140,248,0.42);
|
||||||
|
background: rgba(90,140,248,0.11);
|
||||||
|
}
|
||||||
|
.file-search-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.file-search-icon .language-icon {
|
||||||
|
display: block;
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
.file-search-icon .language-icon path { fill: currentColor; }
|
||||||
|
.file-search-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.file-search-main strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.file-search-main span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.file-search-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.file-search-history {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(7, 8, 16, 0.18);
|
||||||
|
}
|
||||||
|
.file-search-history-head {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
background: rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
.file-search-history-head span {
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.file-search-history-head strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.file-search-history-list {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.file-search-history-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
}
|
||||||
|
.file-search-history-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.file-search-history-main .hash {
|
||||||
|
padding: 2px 7px;
|
||||||
|
border: 1px solid rgba(90,140,248,0.22);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
background: rgba(90,140,248,0.13);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.file-search-history-main strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 12.5px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.file-search-history-main > span:last-child {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-size: 11.5px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.file-search-history-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Credential dialog --- */
|
/* --- Credential dialog --- */
|
||||||
|
|
||||||
.cred-card {
|
.cred-card {
|
||||||
@@ -1929,6 +2228,11 @@
|
|||||||
.dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); }
|
.dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); }
|
||||||
.compare-dialog .dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); }
|
.compare-dialog .dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); }
|
||||||
.global-search-options { grid-template-columns: 1fr; }
|
.global-search-options { grid-template-columns: 1fr; }
|
||||||
|
.file-search-split { grid-template-columns: 1fr; grid-template-rows: minmax(150px, 0.9fr) minmax(220px, 1fr); }
|
||||||
|
.file-search-column { border-right: none; border-bottom: 1px solid var(--color-border-subtle); }
|
||||||
|
.file-search-hit { grid-template-columns: auto minmax(0, 1fr); align-items: start; }
|
||||||
|
.file-search-hit .status-badge,
|
||||||
|
.file-search-action { grid-column: 2; justify-self: start; }
|
||||||
.dialog-files { border-right: none; border-bottom: 1px solid var(--color-border-subtle); }
|
.dialog-files { border-right: none; border-bottom: 1px solid var(--color-border-subtle); }
|
||||||
.branch-actions { flex-direction: row; justify-content: flex-start; }
|
.branch-actions { flex-direction: row; justify-content: flex-start; }
|
||||||
.tb-action-label { display: none; }
|
.tb-action-label { display: none; }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { GitBranch, GitMerge } from "@lucide/svelte";
|
import { Check, ChevronDown, ChevronRight, GitBranch, GitMerge, Plus, X } from "@lucide/svelte";
|
||||||
import type { GitBranch as GitBranchInfo } from "../types";
|
import type { GitBranch as GitBranchInfo } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onCheckout: (branch: GitBranchInfo) => void;
|
onCheckout: (branch: GitBranchInfo) => void;
|
||||||
onMerge: (branch: GitBranchInfo) => void;
|
onMerge: (branch: GitBranchInfo) => void;
|
||||||
|
onCreateBranch: (branchName: string) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -20,7 +21,35 @@
|
|||||||
isBusy = false,
|
isBusy = false,
|
||||||
onCheckout = () => {},
|
onCheckout = () => {},
|
||||||
onMerge = () => {},
|
onMerge = () => {},
|
||||||
|
onCreateBranch = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let localOpen = $state(true);
|
||||||
|
let remoteOpen = $state(false);
|
||||||
|
let createOpen = $state(false);
|
||||||
|
let newBranchName = $state("");
|
||||||
|
let createInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
function openCreateForm() {
|
||||||
|
if (!hasRepository || isBusy) return;
|
||||||
|
createOpen = true;
|
||||||
|
queueMicrotask(() => createInput?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateForm() {
|
||||||
|
createOpen = false;
|
||||||
|
newBranchName = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const value = newBranchName.trim();
|
||||||
|
if (!value || !hasRepository || isBusy) return;
|
||||||
|
await onCreateBranch(value);
|
||||||
|
newBranchName = "";
|
||||||
|
createOpen = false;
|
||||||
|
localOpen = true;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
||||||
@@ -29,22 +58,69 @@
|
|||||||
<span class="eyebrow">Branches</span>
|
<span class="eyebrow">Branches</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Refs</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Refs</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="branch-head-actions">
|
||||||
|
<button
|
||||||
|
class="branch-create-toggle"
|
||||||
|
type="button"
|
||||||
|
onclick={openCreateForm}
|
||||||
|
disabled={!hasRepository || isBusy}
|
||||||
|
title="Create new branch"
|
||||||
|
aria-label="Create new branch"
|
||||||
|
>
|
||||||
|
<Plus size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
<span class="pill pill-count">{branches.length}</span>
|
<span class="pill pill-count">{branches.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if !hasRepository}
|
{#if !hasRepository}
|
||||||
<p class="blank-state">Open a repository to list branches.</p>
|
<p class="blank-state">Open a repository to list branches.</p>
|
||||||
{:else if branches.length === 0}
|
{:else if branches.length === 0}
|
||||||
<p class="blank-state">No branches returned.</p>
|
<p class="blank-state">No branches returned.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-auto p-2 flex flex-col gap-0">
|
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
|
||||||
{#if localBranches.length > 0}
|
{#if createOpen}
|
||||||
<div class="branch-group-label">
|
<form class="branch-create-form" onsubmit={submitCreate}>
|
||||||
|
<GitBranch size={15} aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
bind:this={createInput}
|
||||||
|
bind:value={newBranchName}
|
||||||
|
disabled={isBusy}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="new-branch-name"
|
||||||
|
aria-label="New branch name"
|
||||||
|
/>
|
||||||
|
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newBranchName.trim().length === 0} title="Create branch">
|
||||||
|
<Check size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button class="branch-create-action" type="button" onclick={closeCreateForm} disabled={isBusy} title="Cancel">
|
||||||
|
<X size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="branch-group">
|
||||||
|
<button
|
||||||
|
class="branch-group-toggle"
|
||||||
|
type="button"
|
||||||
|
onclick={() => { localOpen = !localOpen; }}
|
||||||
|
aria-expanded={localOpen}
|
||||||
|
>
|
||||||
|
{#if localOpen}
|
||||||
|
<ChevronDown size={14} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<ChevronRight size={14} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
<span>Local</span>
|
<span>Local</span>
|
||||||
<span class="branch-group-count">{localBranches.length}</span>
|
<span class="branch-group-count">{localBranches.length}</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
|
{#if localOpen}
|
||||||
|
{#if localBranches.length === 0}
|
||||||
|
<div class="branch-empty">No local branches.</div>
|
||||||
|
{:else}
|
||||||
{#each localBranches as branch (branch.name)}
|
{#each localBranches as branch (branch.name)}
|
||||||
{#snippet branchCard()}
|
|
||||||
<article class="branch-row" class:current={branch.current}>
|
<article class="branch-row" class:current={branch.current}>
|
||||||
<div class="branch-info">
|
<div class="branch-info">
|
||||||
<GitBranch size={16} aria-hidden="true" />
|
<GitBranch size={16} aria-hidden="true" />
|
||||||
@@ -67,16 +143,31 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
{/snippet}
|
|
||||||
{@render branchCard()}
|
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if remoteBranches.length > 0}
|
<div class="branch-group">
|
||||||
<div class="branch-group-label" style="margin-top: {localBranches.length > 0 ? '12px' : '0'}">
|
<button
|
||||||
|
class="branch-group-toggle"
|
||||||
|
type="button"
|
||||||
|
onclick={() => { remoteOpen = !remoteOpen; }}
|
||||||
|
aria-expanded={remoteOpen}
|
||||||
|
>
|
||||||
|
{#if remoteOpen}
|
||||||
|
<ChevronDown size={14} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<ChevronRight size={14} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
<span>Remote</span>
|
<span>Remote</span>
|
||||||
<span class="branch-group-count">{remoteBranches.length}</span>
|
<span class="branch-group-count">{remoteBranches.length}</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
|
{#if remoteOpen}
|
||||||
|
{#if remoteBranches.length === 0}
|
||||||
|
<div class="branch-empty">No remote branches.</div>
|
||||||
|
{:else}
|
||||||
{#each remoteBranches as branch (branch.name)}
|
{#each remoteBranches as branch (branch.name)}
|
||||||
<article class="branch-row" class:current={branch.current}>
|
<article class="branch-row" class:current={branch.current}>
|
||||||
<div class="branch-info">
|
<div class="branch-info">
|
||||||
@@ -102,6 +193,8 @@
|
|||||||
</article>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CalendarDays, FileCode, GitCompare, LoaderCircle, Search, User, X } from "@lucide/svelte";
|
import {
|
||||||
import type { GitSearchHit } from "../types";
|
CalendarDays,
|
||||||
|
FileCode,
|
||||||
|
FileText,
|
||||||
|
GitCompare,
|
||||||
|
History,
|
||||||
|
LoaderCircle,
|
||||||
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
User,
|
||||||
|
X,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
import { languageIconForPath } from "../languageIcons";
|
||||||
|
import type { GitCommit, GitRepositoryFile, GitSearchHit } from "../types";
|
||||||
|
import LanguageIcon from "./LanguageIcon.svelte";
|
||||||
|
|
||||||
|
type SearchTab = "code" | "files";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
@@ -8,10 +23,16 @@
|
|||||||
isSearching: boolean;
|
isSearching: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
results: GitSearchHit[];
|
results: GitSearchHit[];
|
||||||
|
files: GitRepositoryFile[];
|
||||||
|
fileHistory: GitCommit[];
|
||||||
|
selectedFilePath: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSearch: (query: string, caseSensitive: boolean, limit: number) => void | Promise<void>;
|
onSearch: (query: string, caseSensitive: boolean, limit: number) => void | Promise<void>;
|
||||||
onCancel: () => void | Promise<void>;
|
onCancel: () => void | Promise<void>;
|
||||||
onDiff: (hit: GitSearchHit) => void | Promise<void>;
|
onDiff: (hit: GitSearchHit) => void | Promise<void>;
|
||||||
|
onSelectFile: (file: GitRepositoryFile) => void | Promise<void>;
|
||||||
|
onFileHistoryDiff: (commit: GitCommit) => void | Promise<void>;
|
||||||
|
onFileHistoryRestore: (commit: GitCommit) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -20,17 +41,28 @@
|
|||||||
isSearching = false,
|
isSearching = false,
|
||||||
error = "",
|
error = "",
|
||||||
results = [],
|
results = [],
|
||||||
|
files = [],
|
||||||
|
fileHistory = [],
|
||||||
|
selectedFilePath = "",
|
||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
onSearch = () => {},
|
onSearch = () => {},
|
||||||
onCancel = () => {},
|
onCancel = () => {},
|
||||||
onDiff = () => {},
|
onDiff = () => {},
|
||||||
|
onSelectFile = () => {},
|
||||||
|
onFileHistoryDiff = () => {},
|
||||||
|
onFileHistoryRestore = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let activeTab = $state<SearchTab>("code");
|
||||||
let query = $state("");
|
let query = $state("");
|
||||||
let caseSensitive = $state(false);
|
let caseSensitive = $state(false);
|
||||||
let limit = $state(250);
|
let limit = $state(250);
|
||||||
let searchedQuery = $state("");
|
let searchedQuery = $state("");
|
||||||
let searched = $state(false);
|
let searched = $state(false);
|
||||||
|
let fileQuery = $state("");
|
||||||
|
|
||||||
|
const fileSearchActive = $derived(fileQuery.trim().length > 0);
|
||||||
|
const fileSearchResults = $derived(filterFiles(files, fileQuery, 200));
|
||||||
|
|
||||||
function submit(event?: SubmitEvent) {
|
function submit(event?: SubmitEvent) {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
@@ -56,6 +88,42 @@
|
|||||||
function displayPath(hit: GitSearchHit): string {
|
function displayPath(hit: GitSearchHit): string {
|
||||||
return hit.old_file ? `${hit.old_file} -> ${hit.file}` : hit.file;
|
return hit.old_file ? `${hit.old_file} -> ${hit.file}` : hit.file;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fileName(path: string): string {
|
||||||
|
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderName(path: string): string {
|
||||||
|
const parts = path.split(/[\\/]/).filter(Boolean);
|
||||||
|
parts.pop();
|
||||||
|
return parts.length > 0 ? parts.join("/") : "Repository root";
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterFiles(source: GitRepositoryFile[], value: string, maxResults: number): GitRepositoryFile[] {
|
||||||
|
const terms = value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
if (terms.length === 0) return [];
|
||||||
|
|
||||||
|
return source
|
||||||
|
.filter((file) => {
|
||||||
|
const path = file.path.toLowerCase();
|
||||||
|
const name = fileName(file.path).toLowerCase();
|
||||||
|
return terms.every((term) => path.includes(term) || name.includes(term));
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aName = fileName(a.path).toLowerCase();
|
||||||
|
const bName = fileName(b.path).toLowerCase();
|
||||||
|
const first = terms[0] ?? "";
|
||||||
|
const aStarts = aName.startsWith(first) ? 0 : 1;
|
||||||
|
const bStarts = bName.startsWith(first) ? 0 : 1;
|
||||||
|
if (aStarts !== bStarts) return aStarts - bStarts;
|
||||||
|
return a.path.localeCompare(b.path);
|
||||||
|
})
|
||||||
|
.slice(0, maxResults);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -63,11 +131,11 @@
|
|||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
>
|
>
|
||||||
<div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global code search" tabindex="-1">
|
<div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global search" tabindex="-1">
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Global search</span>
|
<span class="eyebrow">Global search</span>
|
||||||
<h2 class="dialog-title">Find where code was introduced</h2>
|
<h2 class="dialog-title">{activeTab === "code" ? "Find where code was introduced" : "Find file history"}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||||
<X size={18} aria-hidden="true" />
|
<X size={18} aria-hidden="true" />
|
||||||
@@ -75,6 +143,32 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="global-search-body">
|
<div class="global-search-body">
|
||||||
|
<div class="global-search-tabs" role="tablist" aria-label="Search mode">
|
||||||
|
<button
|
||||||
|
class="global-search-tab"
|
||||||
|
class:active={activeTab === "code"}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === "code"}
|
||||||
|
onclick={() => { activeTab = "code"; }}
|
||||||
|
>
|
||||||
|
<Search size={14} aria-hidden="true" />
|
||||||
|
Code
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="global-search-tab"
|
||||||
|
class:active={activeTab === "files"}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === "files"}
|
||||||
|
onclick={() => { activeTab = "files"; }}
|
||||||
|
>
|
||||||
|
<FileText size={14} aria-hidden="true" />
|
||||||
|
Files
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if activeTab === "code"}
|
||||||
<form class="global-search-form" onsubmit={submit}>
|
<form class="global-search-form" onsubmit={submit}>
|
||||||
<label class="global-search-query">
|
<label class="global-search-query">
|
||||||
<span>String or function</span>
|
<span>String or function</span>
|
||||||
@@ -181,6 +275,122 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
{:else}
|
||||||
|
<form class="global-search-form file-search-form" onsubmit={(event) => event.preventDefault()}>
|
||||||
|
<label class="global-search-query">
|
||||||
|
<span>File name or path</span>
|
||||||
|
<input
|
||||||
|
bind:value={fileQuery}
|
||||||
|
disabled={!hasRepository || isBusy}
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="Search files, folders, extensions..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="global-search-results file-search-results" aria-live="polite">
|
||||||
|
{#if !hasRepository}
|
||||||
|
<div class="blank-state">Open a repository first.</div>
|
||||||
|
{:else if files.length === 0}
|
||||||
|
<div class="blank-state">No files loaded for this repository.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="file-search-split">
|
||||||
|
<div class="file-search-column">
|
||||||
|
{#if !fileSearchActive}
|
||||||
|
<div class="blank-state">Search a file name or path, then choose a result to show its history.</div>
|
||||||
|
{:else if fileSearchResults.length === 0}
|
||||||
|
<div class="blank-state">No file found for "{fileQuery.trim()}".</div>
|
||||||
|
{:else}
|
||||||
|
<div class="search-result-head">
|
||||||
|
<strong>{fileSearchResults.length}</strong>
|
||||||
|
<span>{fileSearchResults.length === 1 ? "file" : "files"} found for "{fileQuery.trim()}"</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-search-list">
|
||||||
|
{#each fileSearchResults as file (file.path)}
|
||||||
|
{@const languageIcon = languageIconForPath(file.path)}
|
||||||
|
<button
|
||||||
|
class="file-search-hit"
|
||||||
|
class:active={selectedFilePath === file.path}
|
||||||
|
type="button"
|
||||||
|
disabled={isBusy || isSearching}
|
||||||
|
title={`Show history for ${file.path}`}
|
||||||
|
onclick={() => onSelectFile(file)}
|
||||||
|
>
|
||||||
|
<span class="file-search-icon">
|
||||||
|
{#if languageIcon}
|
||||||
|
<LanguageIcon icon={languageIcon.icon} title={languageIcon.title} />
|
||||||
|
{:else}
|
||||||
|
<FileText size={16} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="file-search-main">
|
||||||
|
<strong>{fileName(file.path)}</strong>
|
||||||
|
<span>{folderName(file.path)}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if file.status}
|
||||||
|
<small class={`status-badge ${file.status}`}>{file.status}</small>
|
||||||
|
{:else if !file.tracked}
|
||||||
|
<small class="status-badge untracked">untracked</small>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span class="file-search-action">
|
||||||
|
<History size={14} aria-hidden="true" />
|
||||||
|
History
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="file-search-history" aria-label="Selected file history">
|
||||||
|
<header class="file-search-history-head">
|
||||||
|
<span>History</span>
|
||||||
|
<strong title={selectedFilePath}>{selectedFilePath ? fileName(selectedFilePath) : "No file selected"}</strong>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if !selectedFilePath}
|
||||||
|
<div class="blank-state">Select a file result to load its commit history.</div>
|
||||||
|
{:else if isBusy}
|
||||||
|
<div class="blank-state">
|
||||||
|
<LoaderCircle class="spin" size={18} aria-hidden="true" />
|
||||||
|
Loading history...
|
||||||
|
</div>
|
||||||
|
{:else if fileHistory.length === 0}
|
||||||
|
<div class="blank-state">No history returned for this file.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="file-search-history-list">
|
||||||
|
{#each fileHistory as commit (commit.hash)}
|
||||||
|
<article class="file-search-history-row">
|
||||||
|
<div class="file-search-history-main">
|
||||||
|
<span class="hash">{commit.short_hash}</span>
|
||||||
|
<strong title={commit.summary}>{commit.summary || "No commit message"}</strong>
|
||||||
|
<span>{commit.author_name || "Unknown author"} - {formatCommitDate(commit.date)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-search-history-actions">
|
||||||
|
<button class="btn-sm" type="button" onclick={() => onFileHistoryDiff(commit)} disabled={isBusy}>
|
||||||
|
<GitCompare size={14} aria-hidden="true" />
|
||||||
|
Diff
|
||||||
|
</button>
|
||||||
|
<button class="btn-sm" type="button" onclick={() => onFileHistoryRestore(commit)} disabled={isBusy}>
|
||||||
|
<RotateCcw size={14} aria-hidden="true" />
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export function checkoutBranch(path: string, branch: string): Promise<GitStatus>
|
|||||||
return invoke<GitStatus>("checkout_branch", { path, branch });
|
return invoke<GitStatus>("checkout_branch", { path, branch });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createBranch(path: string, branch: string): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("create_branch", { path, branch });
|
||||||
|
}
|
||||||
|
|
||||||
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
|
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("stage_files", { path, files });
|
return invoke<GitStatus>("stage_files", { path, files });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user