feat(git): add stash push with optional paths and per-file scope
The stash push feature now supports limiting the stash to selected files via an optional paths parameter. The UI passes file paths to stash_push and adds a per-file context menu for scoped stash operations. - Extend stash API to accept optional paths for scoped stashes - Implement per-file stash actions via a status panel context menu - Update tests and docs to reflect scoped stash behavior
This commit is contained in:
@@ -16,6 +16,9 @@ The project uses calendar-style versions in the form `YYYY.M.PATCH`.
|
||||
- The Changes panel now offers a List/Tree switch. Tree view groups staged and
|
||||
unstaged files into independently collapsible folders while retaining all
|
||||
existing file actions.
|
||||
- Files and folders in the Changes panel now have context-menu actions for
|
||||
staging or unstaging their scope and for creating a stash containing only
|
||||
the selected file or folder.
|
||||
- Gitty can open a repository directly at startup through the `--repo PATH` or
|
||||
`--repo=PATH` command-line argument.
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ The command list below includes the repository-management and synchronization AP
|
||||
- `stage_files(path: string, files: string[]): Promise<GitStatus>`
|
||||
- `unstage_files(path: string, files: string[]): Promise<GitStatus>`
|
||||
- `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>`
|
||||
- `stash_push(path: string, message?: string, includeUntracked?: boolean, paths?: string[]): Promise<GitStatus>`; when `paths` is provided, only matching files are stashed.
|
||||
- `commit(path: string, message: string): Promise<GitStatus>`
|
||||
- `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>`
|
||||
- `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: string): Promise<GitStatus>`
|
||||
|
||||
+62
-12
@@ -1243,25 +1243,45 @@ pub async fn stash_push(
|
||||
path: String,
|
||||
message: Option<String>,
|
||||
include_untracked: bool,
|
||||
paths: Option<Vec<String>>,
|
||||
) -> Result<GitStatus, String> {
|
||||
run_git_task("Could not stash changes", move || {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let trimmed_message = message.unwrap_or_default().trim().to_string();
|
||||
let mut args: Vec<OsString> = vec![OsString::from("stash"), OsString::from("push")];
|
||||
if include_untracked {
|
||||
args.push(OsString::from("--include-untracked"));
|
||||
}
|
||||
if !trimmed_message.is_empty() {
|
||||
args.push(OsString::from("-m"));
|
||||
args.push(OsString::from(trimmed_message));
|
||||
}
|
||||
|
||||
run_git(&repo, args)?;
|
||||
status_for_repo(&repo)
|
||||
stash_push_for_repo(
|
||||
&repo,
|
||||
message.as_deref(),
|
||||
include_untracked,
|
||||
paths.as_deref().unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn stash_push_for_repo(
|
||||
repo: &Path,
|
||||
message: Option<&str>,
|
||||
include_untracked: bool,
|
||||
paths: &[String],
|
||||
) -> Result<GitStatus, String> {
|
||||
validate_files(paths)?;
|
||||
let trimmed_message = message.unwrap_or_default().trim();
|
||||
let mut args: Vec<OsString> = vec![OsString::from("stash"), OsString::from("push")];
|
||||
if include_untracked {
|
||||
args.push(OsString::from("--include-untracked"));
|
||||
}
|
||||
if !trimmed_message.is_empty() {
|
||||
args.push(OsString::from("-m"));
|
||||
args.push(OsString::from(trimmed_message));
|
||||
}
|
||||
if !paths.is_empty() {
|
||||
args.push(OsString::from("--"));
|
||||
args.extend(paths.iter().map(OsString::from));
|
||||
}
|
||||
|
||||
run_git(repo, args)?;
|
||||
status_for_repo(repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stash_apply(path: String, selector: String) -> Result<GitStatus, String> {
|
||||
run_git_task("Could not apply stash", move || {
|
||||
@@ -7075,6 +7095,36 @@ mod tests {
|
||||
assert!(status.files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stash_push_can_be_limited_to_selected_files() {
|
||||
let repo = init_temp_repo("scoped_stash");
|
||||
fs::write(repo.path.join("selected.txt"), "initial selected\n")
|
||||
.expect("selected file should be written");
|
||||
fs::write(repo.path.join("remaining.txt"), "initial remaining\n")
|
||||
.expect("remaining file should be written");
|
||||
run_git_test(&repo.path, ["add", "selected.txt", "remaining.txt"]);
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "initial files"]);
|
||||
|
||||
fs::write(repo.path.join("selected.txt"), "stashed change\n")
|
||||
.expect("selected change should be written");
|
||||
fs::write(repo.path.join("remaining.txt"), "remaining change\n")
|
||||
.expect("remaining change should be written");
|
||||
|
||||
let paths = vec!["selected.txt".to_string()];
|
||||
let status = stash_push_for_repo(&repo.path, Some("selected file"), false, &paths)
|
||||
.expect("selected file should be stashed");
|
||||
|
||||
assert!(status.files.iter().all(|file| file.path != "selected.txt"));
|
||||
assert!(status.files.iter().any(|file| file.path == "remaining.txt"));
|
||||
assert_eq!(
|
||||
git_output_test(
|
||||
&repo.path,
|
||||
["stash", "show", "--name-only", "--format=", "stash@{0}"],
|
||||
),
|
||||
"selected.txt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branches_report_configured_upstream_and_local_only_state() {
|
||||
let repo = init_temp_repo("branch_upstream_state");
|
||||
|
||||
+15
-4
@@ -3852,11 +3852,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStash(message: string, includeUntracked: boolean) {
|
||||
if (!activeRepoPath || changedFiles.length === 0) return;
|
||||
const stashedFiles = changedFiles.length;
|
||||
async function saveStash(message: string, includeUntracked: boolean, files?: GitFileStatus[]) {
|
||||
const targets = files ?? changedFiles;
|
||||
if (!activeRepoPath || targets.length === 0) return;
|
||||
const paths = files
|
||||
? [...new Set(files.flatMap((file) => file.old_path ? [file.old_path, file.path] : [file.path]))]
|
||||
: undefined;
|
||||
const stashedFiles = targets.length;
|
||||
await runOperation("Stashing changes", async () => {
|
||||
applyStatus(await stashPush(activeRepoPath, message, includeUntracked));
|
||||
applyStatus(await stashPush(activeRepoPath, message, includeUntracked, paths));
|
||||
await refreshRepositoryViews(activeRepoPath, {
|
||||
branches: false,
|
||||
stashes: true,
|
||||
@@ -3865,10 +3869,16 @@
|
||||
trackEvent("stash_saved", {
|
||||
include_untracked: includeUntracked ? 1 : 0,
|
||||
changed_files: stashedFiles,
|
||||
scoped: files ? 1 : 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function stashStatusFiles(files: GitFileStatus[], label: string) {
|
||||
const suffix = files.length === 1 ? files[0].path : `${label} (${files.length} files)`;
|
||||
await saveStash(`Gitty: ${suffix}`, true, files);
|
||||
}
|
||||
|
||||
async function applyStashEntry(stash: GitStash) {
|
||||
if (!activeRepoPath) return;
|
||||
await runOperation(`Applying ${stash.selector}`, async () => {
|
||||
@@ -5367,6 +5377,7 @@
|
||||
onUnstage={unstageFile}
|
||||
onDiscard={discardFiles}
|
||||
onDiscardMany={discardChanges}
|
||||
onStash={stashStatusFiles}
|
||||
onPatch={openPreferredFileDiff}
|
||||
onStageAll={stageAllFiles}
|
||||
onUnstageAll={unstageAllFiles}
|
||||
|
||||
+131
@@ -2327,6 +2327,7 @@
|
||||
.branch-context-menu,
|
||||
.history-context-menu,
|
||||
.explorer-context-menu,
|
||||
.status-context-menu,
|
||||
.repo-tab-context-menu {
|
||||
z-index: 120;
|
||||
display: grid;
|
||||
@@ -2346,11 +2347,102 @@
|
||||
}
|
||||
.history-context-menu { position: absolute; }
|
||||
.explorer-context-menu,
|
||||
.status-context-menu,
|
||||
.repo-tab-context-menu { position: fixed; }
|
||||
|
||||
.status-context-menu {
|
||||
width: min(280px, calc(100vw - 16px));
|
||||
padding: 6px;
|
||||
border-color: color-mix(in srgb, var(--color-border) 78%, #5a8cf8);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(90,140,248,0.045), transparent 70px),
|
||||
var(--color-surface-solid);
|
||||
}
|
||||
|
||||
.status-context-label {
|
||||
display: grid;
|
||||
grid-template-columns: 32px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
margin: -1px -1px 5px;
|
||||
padding: 8px 8px 10px;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.status-context-object-icon,
|
||||
.status-context-action-icon {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
color: #83adff;
|
||||
background: rgba(90,140,248,0.12);
|
||||
border: 1px solid rgba(90,140,248,0.2);
|
||||
}
|
||||
|
||||
.status-context-object-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.status-context-object-copy,
|
||||
.status-context-action-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-context-kind {
|
||||
margin-bottom: 2px;
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 8.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .1em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-context-object-copy > strong {
|
||||
overflow: hidden;
|
||||
color: var(--color-ink);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-context-path {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
color: var(--color-ink-faint);
|
||||
font: 9.5px/1.2 var(--font-mono);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-context-path svg { flex: 0 0 auto; }
|
||||
|
||||
.status-context-count {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
border: 1px solid rgba(90,140,248,0.18);
|
||||
border-radius: 5px;
|
||||
color: #83adff;
|
||||
background: rgba(90,140,248,0.1);
|
||||
font: 700 9.5px/1 var(--font-mono);
|
||||
}
|
||||
|
||||
.branch-context-menu button,
|
||||
.history-context-menu button,
|
||||
.explorer-context-menu button,
|
||||
.status-context-menu button,
|
||||
.repo-tab-context-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2368,15 +2460,52 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.status-context-menu button {
|
||||
min-height: 42px;
|
||||
gap: 9px;
|
||||
padding: 6px 7px;
|
||||
}
|
||||
|
||||
.status-context-action-icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-color: transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-ink-dim);
|
||||
}
|
||||
|
||||
.status-context-action-copy { gap: 1px; }
|
||||
.status-context-action-copy strong {
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.status-context-action-copy span {
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 9.5px;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.branch-context-menu button:hover:not(:disabled),
|
||||
.history-context-menu button:hover:not(:disabled),
|
||||
.explorer-context-menu button:hover:not(:disabled),
|
||||
.status-context-menu button:hover:not(:disabled),
|
||||
.repo-tab-context-menu button:hover:not(:disabled) {
|
||||
border-color: var(--color-border-subtle);
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.status-context-menu button:hover:not(:disabled) .status-context-action-icon {
|
||||
color: #9bbcff;
|
||||
background: rgba(90,140,248,0.1);
|
||||
}
|
||||
.status-context-menu button:hover:not(:disabled) .status-context-action-copy strong {
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.branch-context-menu .menu-separator,
|
||||
.history-context-menu .menu-separator,
|
||||
.repo-tab-context-menu .menu-separator {
|
||||
@@ -2398,6 +2527,7 @@
|
||||
.branch-context-menu button:disabled,
|
||||
.history-context-menu button:disabled,
|
||||
.explorer-context-menu button:disabled,
|
||||
.status-context-menu button:disabled,
|
||||
.repo-tab-context-menu button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
@@ -7651,6 +7781,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) 25px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.status-flow-divider::before {
|
||||
inset-block: auto;
|
||||
inset-inline: 0;
|
||||
|
||||
@@ -1530,6 +1530,7 @@
|
||||
"Nach einem erfolgreichen Pull erkennt Gitty LFS-Repositories automatisch und lädt die benötigten LFS-Objekte mit demselben Remote und denselben Zugangsdaten. Ein zweiter manueller Pull ist nicht erforderlich.",
|
||||
"Unstaged und Staged stehen jetzt gleich breit nebeneinander, scrollen unabhängig voneinander und verwenden eindeutige Pfeile für Stage und Unstage. Bei schmalen Fenstern wechselt die Darstellung automatisch untereinander.",
|
||||
"Der mittig angeordnete List-/Tree-Umschalter zeigt Änderungen entweder als kompakte Liste oder gruppiert sie in beiden Bereichen nach aufklappbaren Ordnern.",
|
||||
"Über das Kontextmenü einer Datei oder eines Ordners lassen sich gezielt einzelne Dateien oder alle Änderungen im Ordner stagen, unstagen oder in einem eigenen Stash sichern.",
|
||||
"Repositories können beim Start über --repo PATH oder --repo=PATH direkt geöffnet werden. Relative Pfade werden dabei aufgelöst.",
|
||||
"Quadratische Bedienelemente und Flächen vereinheitlichen das Erscheinungsbild; runde Statuspunkte, Avatare und charakteristische Branch-Markierungen bleiben erhalten.",
|
||||
],
|
||||
@@ -1645,6 +1646,7 @@
|
||||
"After a successful pull, Gitty automatically detects LFS repositories and downloads the required LFS objects with the same remote and credentials. A second manual pull is no longer required.",
|
||||
"Unstaged and Staged now sit side by side at equal width, scroll independently, and use clear arrows for Stage and Unstage. Narrow windows automatically fall back to a vertical layout.",
|
||||
"The centered List/Tree switch presents changes either as a compact list or groups them into collapsible folders in both areas.",
|
||||
"A file or folder context menu can stage, unstage, or save only that file or the folder's complete set of changes in a dedicated stash.",
|
||||
"Repositories can be opened directly at startup with --repo PATH or --repo=PATH. Relative paths are resolved automatically.",
|
||||
"Square controls and surfaces make the interface more consistent while circular status markers, avatars, and characteristic branch shapes remain intact.",
|
||||
],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Archive,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
FileDiff,
|
||||
@@ -25,6 +26,7 @@
|
||||
onUnstage: (files: GitFileStatus[]) => void;
|
||||
onDiscard: (files: GitFileStatus[], staged: boolean) => void;
|
||||
onDiscardMany: (files: GitFileStatus[]) => void;
|
||||
onStash: (files: GitFileStatus[], label: string) => void;
|
||||
onPatch: (file: GitFileStatus, staged: boolean) => void;
|
||||
onStageAll: () => void;
|
||||
onUnstageAll: () => void;
|
||||
@@ -52,6 +54,13 @@
|
||||
|
||||
type StatusTreeNode = StatusFolderNode | StatusFileNode;
|
||||
|
||||
interface StatusContextTarget {
|
||||
lane: StatusLaneKind;
|
||||
kind: "file" | "folder";
|
||||
label: string;
|
||||
files: GitFileStatus[];
|
||||
}
|
||||
|
||||
let {
|
||||
changedFiles = [],
|
||||
stagedCount = 0,
|
||||
@@ -66,6 +75,7 @@
|
||||
onUnstage = () => {},
|
||||
onDiscard = () => {},
|
||||
onDiscardMany = () => {},
|
||||
onStash = () => {},
|
||||
onPatch = () => {},
|
||||
onStageAll = () => {},
|
||||
onUnstageAll = () => {},
|
||||
@@ -175,6 +185,9 @@
|
||||
let selectionAnchorKey = $state("");
|
||||
let statusView = $state<StatusViewMode>("list");
|
||||
let collapsedStatusFolders = $state<Set<string>>(new Set());
|
||||
let statusContextTarget = $state<StatusContextTarget | null>(null);
|
||||
let statusContextMenuX = $state(0);
|
||||
let statusContextMenuY = $state(0);
|
||||
|
||||
function toggleStatusFolder(lane: StatusLaneKind, path: string) {
|
||||
const next = new Set(collapsedStatusFolders);
|
||||
@@ -184,6 +197,65 @@
|
||||
collapsedStatusFolders = next;
|
||||
}
|
||||
|
||||
function filesInStatusFolder(lane: StatusLaneKind, path: string): GitFileStatus[] {
|
||||
const prefix = `${path.replace(/\\/g, "/").replace(/\/$/, "")}/`;
|
||||
const files = lane === "unstaged" ? unstagedFiles : stagedFiles;
|
||||
return files.filter((file) => {
|
||||
const currentPath = file.path.replace(/\\/g, "/");
|
||||
const oldPath = file.old_path?.replace(/\\/g, "/") ?? "";
|
||||
return currentPath.startsWith(prefix) || oldPath.startsWith(prefix);
|
||||
});
|
||||
}
|
||||
|
||||
function openStatusContextMenu(
|
||||
event: MouseEvent,
|
||||
lane: StatusLaneKind,
|
||||
kind: "file" | "folder",
|
||||
label: string,
|
||||
files: GitFileStatus[],
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (files.length === 0) return;
|
||||
statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - 288));
|
||||
statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - 174));
|
||||
statusContextTarget = { lane, kind, label, files };
|
||||
}
|
||||
|
||||
function statusContextName(label: string): string {
|
||||
const normalized = label.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
return normalized.split("/").pop() || label;
|
||||
}
|
||||
|
||||
function statusContextParent(label: string): string {
|
||||
const normalized = label.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const separator = normalized.lastIndexOf("/");
|
||||
return separator > 0 ? normalized.slice(0, separator) : "Repository root";
|
||||
}
|
||||
|
||||
function closeStatusContextMenu() {
|
||||
statusContextTarget = null;
|
||||
}
|
||||
|
||||
function runStatusContextStageAction() {
|
||||
const target = statusContextTarget;
|
||||
if (!target) return;
|
||||
closeStatusContextMenu();
|
||||
if (target.lane === "unstaged") onStage(target.files);
|
||||
else onUnstage(target.files);
|
||||
}
|
||||
|
||||
function runStatusContextStashAction() {
|
||||
const target = statusContextTarget;
|
||||
if (!target) return;
|
||||
closeStatusContextMenu();
|
||||
onStash(target.files, target.label);
|
||||
}
|
||||
|
||||
function handleStatusWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") closeStatusContextMenu();
|
||||
}
|
||||
|
||||
function isStatusSelected(file: GitFileStatus): boolean {
|
||||
return selectedStatusPaths.has(fileKey(file));
|
||||
}
|
||||
@@ -277,6 +349,8 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={closeStatusContextMenu} onkeydown={handleStatusWindowKeydown} />
|
||||
|
||||
<section class="panel status-panel relative grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Working tree status">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
@@ -336,7 +410,7 @@
|
||||
<div class="status-file-list">
|
||||
{#each visibleUnstagedRows as row (`unstaged:${row.kind}:${row.kind === "file" ? fileKey(row.file) : row.path}`)}
|
||||
{#if row.kind === "folder"}
|
||||
<button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("unstaged", row.path)} title={row.path} aria-expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)}>
|
||||
<button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("unstaged", row.path)} oncontextmenu={(event) => openStatusContextMenu(event, "unstaged", "folder", row.path, filesInStatusFolder("unstaged", row.path))} title={row.path} aria-expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)}>
|
||||
<i class:expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)} class="status-tree-chevron" aria-hidden="true"></i>
|
||||
{#if collapsedStatusFolders.has(`unstaged:${row.path}`)}<Folder class="status-folder-lucide" size={15} aria-hidden="true" />{:else}<FolderOpen class="status-folder-lucide" size={15} aria-hidden="true" />{/if}
|
||||
<strong>{row.name}</strong>
|
||||
@@ -345,7 +419,7 @@
|
||||
{:else}
|
||||
{@const file = row.file}
|
||||
{@const stageTargets = selectedStageTargets(file)}
|
||||
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
|
||||
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "unstaged", "file", file.path, [file])}>
|
||||
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||
<strong>{fileName(file)}</strong>
|
||||
<span>{displayPath(file)}</span>
|
||||
@@ -393,7 +467,7 @@
|
||||
<div class="status-file-list">
|
||||
{#each visibleStagedRows as row (`staged:${row.kind}:${row.kind === "file" ? fileKey(row.file) : row.path}`)}
|
||||
{#if row.kind === "folder"}
|
||||
<button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("staged", row.path)} title={row.path} aria-expanded={!collapsedStatusFolders.has(`staged:${row.path}`)}>
|
||||
<button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("staged", row.path)} oncontextmenu={(event) => openStatusContextMenu(event, "staged", "folder", row.path, filesInStatusFolder("staged", row.path))} title={row.path} aria-expanded={!collapsedStatusFolders.has(`staged:${row.path}`)}>
|
||||
<i class:expanded={!collapsedStatusFolders.has(`staged:${row.path}`)} class="status-tree-chevron" aria-hidden="true"></i>
|
||||
{#if collapsedStatusFolders.has(`staged:${row.path}`)}<Folder class="status-folder-lucide" size={15} aria-hidden="true" />{:else}<FolderOpen class="status-folder-lucide" size={15} aria-hidden="true" />{/if}
|
||||
<strong>{row.name}</strong>
|
||||
@@ -402,7 +476,7 @@
|
||||
{:else}
|
||||
{@const file = row.file}
|
||||
{@const unstageTargets = selectedUnstageTargets(file)}
|
||||
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
|
||||
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "staged", "file", file.path, [file])}>
|
||||
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||
<strong>{fileName(file)}</strong>
|
||||
<span>{displayPath(file)}</span>
|
||||
@@ -442,6 +516,40 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if statusContextTarget}
|
||||
<div class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={`Actions for ${statusContextTarget.label}`} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
|
||||
<div class="status-context-label">
|
||||
<span class="status-context-object-icon" aria-hidden="true">
|
||||
{#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else}<FileDiff size={16} />{/if}
|
||||
</span>
|
||||
<span class="status-context-object-copy">
|
||||
<span class="status-context-kind">{statusContextTarget.lane === "unstaged" ? "Unstaged" : "Staged"} {statusContextTarget.kind}</span>
|
||||
<strong title={statusContextTarget.label}>{statusContextName(statusContextTarget.label)}</strong>
|
||||
<span class="status-context-path" title={statusContextTarget.label}><Folder size={10} aria-hidden="true" />{statusContextParent(statusContextTarget.label)}</span>
|
||||
</span>
|
||||
<span class="status-context-count" title={`${statusContextTarget.files.length} ${statusContextTarget.files.length === 1 ? "file" : "files"}`}>
|
||||
{statusContextTarget.files.length}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" role="menuitem" onclick={runStatusContextStageAction} disabled={isBusy}>
|
||||
<span class="status-context-action-icon" aria-hidden="true">
|
||||
{#if statusContextTarget.lane === "unstaged"}<ArrowRight size={15} />{:else}<ArrowLeft size={15} />{/if}
|
||||
</span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>{statusContextTarget.lane === "unstaged" ? "Stage" : "Unstage"} {statusContextTarget.kind}</strong>
|
||||
<span>{statusContextTarget.lane === "unstaged" ? "Add to the next commit" : "Move back to working changes"}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={runStatusContextStashAction} disabled={isBusy}>
|
||||
<span class="status-context-action-icon" aria-hidden="true"><Archive size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Stash {statusContextTarget.kind}</strong>
|
||||
<span>Save {statusContextTarget.files.length === 1 ? "this file" : `${statusContextTarget.files.length} files`} for later</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.status-panel-overlay {
|
||||
position: absolute;
|
||||
|
||||
@@ -332,11 +332,13 @@ export function stashPush(
|
||||
path: string,
|
||||
message?: string,
|
||||
includeUntracked = true,
|
||||
paths?: string[],
|
||||
): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("stash_push", {
|
||||
path,
|
||||
message: message?.trim() ? message.trim() : null,
|
||||
includeUntracked,
|
||||
paths: paths?.length ? paths : null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user