增强git贮藏(stash)功能,支持可视化操作
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["welcome", "folder-*", "commit-*", "merge-*", "settings"],
|
||||
"windows": ["welcome", "folder-*", "commit-*", "merge-*", "stash-*", "settings"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:default",
|
||||
|
||||
@@ -78,6 +78,15 @@ pub struct GitCommitResult {
|
||||
pub committed_files: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GitStashEntry {
|
||||
pub index: usize,
|
||||
pub message: String,
|
||||
pub branch: String,
|
||||
pub date: String,
|
||||
pub ref_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GitRemote {
|
||||
pub name: String,
|
||||
@@ -867,24 +876,47 @@ pub async fn git_list_branches(path: String) -> Result<Vec<String>, AppCommandEr
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_stash(path: String) -> Result<String, AppCommandError> {
|
||||
pub async fn git_stash_push(
|
||||
path: String,
|
||||
message: Option<String>,
|
||||
keep_index: bool,
|
||||
) -> Result<String, AppCommandError> {
|
||||
let mut args = vec!["stash".to_string(), "push".to_string()];
|
||||
if let Some(msg) = message {
|
||||
if !msg.is_empty() {
|
||||
args.push("-m".to_string());
|
||||
args.push(msg);
|
||||
}
|
||||
}
|
||||
if keep_index {
|
||||
args.push("--keep-index".to_string());
|
||||
}
|
||||
let output = crate::process::tokio_command("git")
|
||||
.args(["stash"])
|
||||
.args(&args)
|
||||
.current_dir(&path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(git_command_error("stash", &output.stderr));
|
||||
return Err(git_command_error("stash push", &output.stderr));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_stash_pop(path: String) -> Result<String, AppCommandError> {
|
||||
pub async fn git_stash_pop(
|
||||
path: String,
|
||||
stash_ref: Option<String>,
|
||||
) -> Result<String, AppCommandError> {
|
||||
let mut args = vec!["stash", "pop"];
|
||||
let stash_ref_val;
|
||||
if let Some(ref r) = stash_ref {
|
||||
stash_ref_val = r.clone();
|
||||
args.push(&stash_ref_val);
|
||||
}
|
||||
let output = crate::process::tokio_command("git")
|
||||
.args(["stash", "pop"])
|
||||
.args(&args)
|
||||
.current_dir(&path)
|
||||
.output()
|
||||
.await
|
||||
@@ -896,6 +928,149 @@ pub async fn git_stash_pop(path: String) -> Result<String, AppCommandError> {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_stash_list(path: String) -> Result<Vec<GitStashEntry>, AppCommandError> {
|
||||
let output = crate::process::tokio_command("git")
|
||||
.args(["stash", "list", "--format=%gd||%gs||%ci"])
|
||||
.current_dir(&path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(git_command_error("stash list", &output.stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let entries = stdout
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.enumerate()
|
||||
.filter_map(|(i, line)| {
|
||||
let parts: Vec<&str> = line.splitn(3, "||").collect();
|
||||
if parts.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let ref_name = parts[0].to_string();
|
||||
let subject = parts[1];
|
||||
let date = parts[2].to_string();
|
||||
|
||||
// Parse branch and message from subject like "On branch: message" or "WIP on branch: hash"
|
||||
let (branch, message) = if let Some(rest) = subject.strip_prefix("On ") {
|
||||
if let Some(colon_pos) = rest.find(": ") {
|
||||
let branch = rest[..colon_pos].to_string();
|
||||
let msg = rest[colon_pos + 2..].to_string();
|
||||
(branch, msg)
|
||||
} else {
|
||||
(String::new(), subject.to_string())
|
||||
}
|
||||
} else if let Some(rest) = subject.strip_prefix("WIP on ") {
|
||||
if let Some(colon_pos) = rest.find(": ") {
|
||||
let branch = rest[..colon_pos].to_string();
|
||||
let msg = rest[colon_pos + 2..].to_string();
|
||||
(branch, msg)
|
||||
} else {
|
||||
(String::new(), subject.to_string())
|
||||
}
|
||||
} else {
|
||||
(String::new(), subject.to_string())
|
||||
};
|
||||
|
||||
Some(GitStashEntry {
|
||||
index: i,
|
||||
message,
|
||||
branch,
|
||||
date,
|
||||
ref_name,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_stash_apply(
|
||||
path: String,
|
||||
stash_ref: String,
|
||||
) -> Result<String, AppCommandError> {
|
||||
let output = crate::process::tokio_command("git")
|
||||
.args(["stash", "apply", &stash_ref])
|
||||
.current_dir(&path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(git_command_error("stash apply", &output.stderr));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_stash_drop(
|
||||
path: String,
|
||||
stash_ref: String,
|
||||
) -> Result<String, AppCommandError> {
|
||||
let output = crate::process::tokio_command("git")
|
||||
.args(["stash", "drop", &stash_ref])
|
||||
.current_dir(&path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(git_command_error("stash drop", &output.stderr));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_stash_clear(path: String) -> Result<String, AppCommandError> {
|
||||
let output = crate::process::tokio_command("git")
|
||||
.args(["stash", "clear"])
|
||||
.current_dir(&path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(git_command_error("stash clear", &output.stderr));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_stash_show(
|
||||
path: String,
|
||||
stash_ref: String,
|
||||
) -> Result<Vec<GitStatusEntry>, AppCommandError> {
|
||||
let output = crate::process::tokio_command("git")
|
||||
.args(["stash", "show", "--name-status", &stash_ref])
|
||||
.current_dir(&path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(AppCommandError::io)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(git_command_error("stash show", &output.stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let entries = stdout
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.filter_map(|line| {
|
||||
let mut parts = line.splitn(2, '\t');
|
||||
let status = parts.next()?.trim().to_string();
|
||||
let file = parts.next()?.trim().to_string();
|
||||
Some(GitStatusEntry { status, file })
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_status(path: String) -> Result<Vec<GitStatusEntry>, AppCommandError> {
|
||||
let output = crate::process::tokio_command("git")
|
||||
|
||||
@@ -558,3 +558,42 @@ pub fn open_welcome_window(app: &AppHandle) -> Result<(), AppCommandError> {
|
||||
ensure_windows_undecorated(&welcome_window);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_stash_window(
|
||||
app: AppHandle,
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
folder_id: i32,
|
||||
) -> Result<(), AppCommandError> {
|
||||
let label = format!("stash-{folder_id}");
|
||||
|
||||
if let Some(existing) = app.get_webview_window(&label) {
|
||||
ensure_windows_undecorated(&existing);
|
||||
let _ = existing.unminimize();
|
||||
existing
|
||||
.set_focus()
|
||||
.map_err(|e| AppCommandError::window("Failed to focus stash window", e.to_string()))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let folder = crate::db::service::folder_service::get_folder_by_id(&db.conn, folder_id)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?
|
||||
.ok_or_else(|| {
|
||||
AppCommandError::not_found(format!("Folder {folder_id} not found"))
|
||||
.with_detail(format!("folder_id={folder_id}"))
|
||||
})?;
|
||||
|
||||
let url = WebviewUrl::App(format!("stash?folderId={folder_id}").into());
|
||||
let builder = WebviewWindowBuilder::new(&app, &label, url)
|
||||
.title(format!("Stash - {}", folder.name))
|
||||
.inner_size(1100.0, 700.0)
|
||||
.min_inner_size(800.0, 500.0)
|
||||
.center();
|
||||
let stash_window = apply_platform_window_style(builder)
|
||||
.build()
|
||||
.map_err(|e| AppCommandError::window("Failed to open stash window", e.to_string()))?;
|
||||
ensure_windows_undecorated(&stash_window);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -209,8 +209,13 @@ pub fn run() {
|
||||
folders::git_worktree_add,
|
||||
folders::git_checkout,
|
||||
folders::git_list_branches,
|
||||
folders::git_stash,
|
||||
folders::git_stash_push,
|
||||
folders::git_stash_pop,
|
||||
folders::git_stash_list,
|
||||
folders::git_stash_apply,
|
||||
folders::git_stash_drop,
|
||||
folders::git_stash_clear,
|
||||
folders::git_stash_show,
|
||||
folders::git_status,
|
||||
folders::git_is_tracked,
|
||||
folders::git_diff,
|
||||
@@ -254,6 +259,7 @@ pub fn run() {
|
||||
windows::list_open_folders,
|
||||
windows::focus_folder_window,
|
||||
windows::open_merge_window,
|
||||
windows::open_stash_window,
|
||||
system_settings::get_system_proxy_settings,
|
||||
system_settings::update_system_proxy_settings,
|
||||
system_settings::get_system_language_settings,
|
||||
|
||||
111
src/app/stash/page.tsx
Normal file
111
src/app/stash/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { StashWorkspace } from "@/components/layout/unstash-dialog"
|
||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||
import { AppToaster } from "@/components/ui/app-toaster"
|
||||
import { getFolder } from "@/lib/tauri"
|
||||
import type { FolderDetail } from "@/lib/types"
|
||||
|
||||
const TOAST_DURATION_MS = 6000
|
||||
|
||||
interface FolderLoadState {
|
||||
loadedId: number | null
|
||||
folder: FolderDetail | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function StashPageInner() {
|
||||
const t = useTranslations("Folder.branchDropdown.unstashDialog")
|
||||
const searchParams = useSearchParams()
|
||||
const [state, setState] = useState<FolderLoadState>({
|
||||
loadedId: null,
|
||||
folder: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const folderId = Number(searchParams.get("folderId") ?? "0")
|
||||
const normalizedFolderId = Number.isFinite(folderId) ? folderId : 0
|
||||
const hasValidFolderId = normalizedFolderId > 0
|
||||
const loading = hasValidFolderId && state.loadedId !== normalizedFolderId
|
||||
const folder = state.loadedId === normalizedFolderId ? state.folder : null
|
||||
const error = state.loadedId === normalizedFolderId ? state.error : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasValidFolderId) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
getFolder(normalizedFolderId)
|
||||
.then((detail) => {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
loadedId: normalizedFolderId,
|
||||
folder: detail,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
loadedId: normalizedFolderId,
|
||||
folder: null,
|
||||
error: String(err),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [hasValidFolderId, normalizedFolderId])
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
|
||||
<AppTitleBar
|
||||
center={
|
||||
<div className="text-sm font-semibold tracking-tight">
|
||||
{t("title")}
|
||||
{hasValidFolderId && folder ? ` · ${folder.name}` : ""}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<main className="min-h-0 flex-1">
|
||||
{!hasValidFolderId ? (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
Invalid folder ID
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : folder ? (
|
||||
<StashWorkspace folderPath={folder.path} />
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
<AppToaster
|
||||
position="bottom-right"
|
||||
duration={TOAST_DURATION_MS}
|
||||
closeButton
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StashPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<StashPageInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -75,16 +75,16 @@ import {
|
||||
gitMerge,
|
||||
gitRebase,
|
||||
gitDeleteBranch,
|
||||
gitStash,
|
||||
gitStashPop,
|
||||
openFolderWindow,
|
||||
openCommitWindow,
|
||||
setFolderParentBranch,
|
||||
gitListConflicts,
|
||||
gitHasMergeHead,
|
||||
openStashWindow,
|
||||
} from "@/lib/tauri"
|
||||
import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog"
|
||||
import { ConflictDialog } from "@/components/layout/conflict-dialog"
|
||||
import { StashDialog } from "@/components/layout/stash-dialog"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import { toErrorMessage } from "@/lib/app-error"
|
||||
import type { GitBranchList, GitConflictInfo } from "@/lib/types"
|
||||
@@ -138,6 +138,7 @@ export function BranchDropdown({
|
||||
const [worktreeBranchName, setWorktreeBranchName] = useState("")
|
||||
const [worktreePath, setWorktreePath] = useState("")
|
||||
const [manageRemotesOpen, setManageRemotesOpen] = useState(false)
|
||||
const [stashDialogOpen, setStashDialogOpen] = useState(false)
|
||||
const [conflictInfo, setConflictInfo] = useState<GitConflictInfo | null>(null)
|
||||
const taskSeq = useRef(0)
|
||||
const worktreeBranchSet = useMemo(
|
||||
@@ -761,18 +762,23 @@ export function BranchDropdown({
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
disabled={loading}
|
||||
onSelect={() =>
|
||||
runGitTask(t("tasks.stashChanges"), () => gitStash(folderPath))
|
||||
}
|
||||
onSelect={() => {
|
||||
setDropdownOpen(false)
|
||||
setStashDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
{t("stashChanges")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={loading}
|
||||
onSelect={() =>
|
||||
runGitTask(t("tasks.stashPop"), () => gitStashPop(folderPath))
|
||||
}
|
||||
onSelect={() => {
|
||||
if (!folder) return
|
||||
openStashWindow(folder.id).catch((err) => {
|
||||
const msg = toErrorMessage(err)
|
||||
pushAlert("error", t("stashPop"), msg)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<ArchiveRestore className="h-3.5 w-3.5" />
|
||||
{t("stashPop")}
|
||||
@@ -1002,6 +1008,13 @@ export function BranchDropdown({
|
||||
onClose={() => setConflictInfo(null)}
|
||||
onResolved={onBranchChange}
|
||||
/>
|
||||
|
||||
<StashDialog
|
||||
open={stashDialogOpen}
|
||||
folderPath={folderPath}
|
||||
onClose={() => setStashDialogOpen(false)}
|
||||
onStashed={onBranchChange}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
117
src/components/layout/stash-dialog.tsx
Normal file
117
src/components/layout/stash-dialog.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { gitStashPush } from "@/lib/tauri"
|
||||
import { toErrorMessage } from "@/lib/app-error"
|
||||
|
||||
interface StashDialogProps {
|
||||
open: boolean
|
||||
folderPath: string
|
||||
onClose: () => void
|
||||
onStashed: () => void
|
||||
}
|
||||
|
||||
export function StashDialog({
|
||||
open,
|
||||
folderPath,
|
||||
onClose,
|
||||
onStashed,
|
||||
}: StashDialogProps) {
|
||||
const t = useTranslations("Folder.branchDropdown.stashDialog")
|
||||
const [message, setMessage] = useState("")
|
||||
const [keepIndex, setKeepIndex] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
function handleClose() {
|
||||
if (loading) return
|
||||
setMessage("")
|
||||
setKeepIndex(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
async function handleStash() {
|
||||
setLoading(true)
|
||||
try {
|
||||
await gitStashPush(folderPath, message.trim() || undefined, keepIndex)
|
||||
toast.success(t("success"))
|
||||
setMessage("")
|
||||
setKeepIndex(false)
|
||||
onStashed()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.error(t("error"), { description: toErrorMessage(err) })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stash-message">{t("messageLabel")}</Label>
|
||||
<Input
|
||||
id="stash-message"
|
||||
placeholder={t("messagePlaceholder")}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !loading) {
|
||||
handleStash()
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="keep-index"
|
||||
checked={keepIndex}
|
||||
onCheckedChange={setKeepIndex}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Label htmlFor="keep-index">{t("keepIndex")}</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleStash} disabled={loading}>
|
||||
{loading && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
{t("stash")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
541
src/components/layout/unstash-dialog.tsx
Normal file
541
src/components/layout/unstash-dialog.tsx
Normal file
@@ -0,0 +1,541 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Archive, ArchiveRestore, ChevronRight, Loader2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import {
|
||||
FileTree,
|
||||
FileTreeFile,
|
||||
FileTreeFolder,
|
||||
} from "@/components/ai-elements/file-tree"
|
||||
import { DiffViewer } from "@/components/diff/diff-viewer"
|
||||
import {
|
||||
gitStashList,
|
||||
gitStashShow,
|
||||
gitStashApply,
|
||||
gitStashDrop,
|
||||
gitShowFile,
|
||||
} from "@/lib/tauri"
|
||||
import { toErrorMessage } from "@/lib/app-error"
|
||||
import { languageFromPath } from "@/lib/language-detect"
|
||||
import type { GitStashEntry, GitStatusEntry } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// --- File tree types & builder (same pattern as commit-dialog) ---
|
||||
|
||||
interface TreeFileNode {
|
||||
kind: "file"
|
||||
name: string
|
||||
path: string
|
||||
entry: GitStatusEntry
|
||||
}
|
||||
|
||||
interface TreeDirNode {
|
||||
kind: "dir"
|
||||
name: string
|
||||
path: string
|
||||
children: TreeNode[]
|
||||
}
|
||||
|
||||
type TreeNode = TreeFileNode | TreeDirNode
|
||||
|
||||
function buildFileTree(entries: GitStatusEntry[]): TreeNode[] {
|
||||
type BuildDir = {
|
||||
name: string
|
||||
path: string
|
||||
dirs: Map<string, BuildDir>
|
||||
files: TreeFileNode[]
|
||||
}
|
||||
|
||||
const root: BuildDir = {
|
||||
name: "",
|
||||
path: "",
|
||||
dirs: new Map(),
|
||||
files: [],
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const parts = entry.file.split("/").filter(Boolean)
|
||||
if (parts.length === 0) continue
|
||||
|
||||
let current = root
|
||||
let currentPath = ""
|
||||
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
const part = parts[i]
|
||||
const isLeaf = i === parts.length - 1
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part
|
||||
|
||||
if (isLeaf) {
|
||||
current.files.push({
|
||||
kind: "file",
|
||||
name: part,
|
||||
path: currentPath,
|
||||
entry,
|
||||
})
|
||||
} else {
|
||||
const found = current.dirs.get(part)
|
||||
if (found) {
|
||||
current = found
|
||||
} else {
|
||||
const next: BuildDir = {
|
||||
name: part,
|
||||
path: currentPath,
|
||||
dirs: new Map(),
|
||||
files: [],
|
||||
}
|
||||
current.dirs.set(part, next)
|
||||
current = next
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sortNodes(nodes: TreeNode[]) {
|
||||
return nodes.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
function toNodes(dir: BuildDir): TreeNode[] {
|
||||
const dirs: TreeNode[] = Array.from(dir.dirs.values()).map((child) => ({
|
||||
kind: "dir",
|
||||
name: child.name,
|
||||
path: child.path,
|
||||
children: toNodes(child),
|
||||
}))
|
||||
return sortNodes([...dirs, ...dir.files])
|
||||
}
|
||||
|
||||
return toNodes(root)
|
||||
}
|
||||
|
||||
function collectDirPaths(entries: GitStatusEntry[]) {
|
||||
const paths = new Set<string>()
|
||||
for (const entry of entries) {
|
||||
const parts = entry.file.split("/").filter(Boolean)
|
||||
if (parts.length < 2) continue
|
||||
let p = ""
|
||||
for (let i = 0; i < parts.length - 1; i += 1) {
|
||||
p = p ? `${p}/${parts[i]}` : parts[i]
|
||||
paths.add(p)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
function statusColor(status: string) {
|
||||
switch (status.charAt(0).toUpperCase()) {
|
||||
case "A":
|
||||
return "text-green-500"
|
||||
case "D":
|
||||
return "text-red-500"
|
||||
case "M":
|
||||
return "text-blue-500"
|
||||
default:
|
||||
return "text-muted-foreground"
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
interface StashWorkspaceProps {
|
||||
folderPath: string
|
||||
}
|
||||
|
||||
export function StashWorkspace({ folderPath }: StashWorkspaceProps) {
|
||||
const t = useTranslations("Folder.branchDropdown.unstashDialog")
|
||||
|
||||
const [stashes, setStashes] = useState<GitStashEntry[]>([])
|
||||
const [expandedStash, setExpandedStash] = useState<string | null>(null)
|
||||
const [stashFiles, setStashFiles] = useState<
|
||||
Record<string, GitStatusEntry[]>
|
||||
>({})
|
||||
const [filesLoading, setFilesLoading] = useState<string | null>(null)
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||
const [selectedStashRef, setSelectedStashRef] = useState<string | null>(null)
|
||||
const [originalContent, setOriginalContent] = useState("")
|
||||
const [modifiedContent, setModifiedContent] = useState("")
|
||||
|
||||
const [listLoading, setListLoading] = useState(false)
|
||||
const [diffLoading, setDiffLoading] = useState(false)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
|
||||
const loadStashes = useCallback(async () => {
|
||||
setListLoading(true)
|
||||
try {
|
||||
const list = await gitStashList(folderPath)
|
||||
setStashes(list)
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
} finally {
|
||||
setListLoading(false)
|
||||
}
|
||||
}, [folderPath])
|
||||
|
||||
useEffect(() => {
|
||||
loadStashes()
|
||||
}, [loadStashes])
|
||||
|
||||
async function handleToggleStash(stashRef: string) {
|
||||
if (expandedStash === stashRef) {
|
||||
setExpandedStash(null)
|
||||
return
|
||||
}
|
||||
setExpandedStash(stashRef)
|
||||
|
||||
if (!stashFiles[stashRef]) {
|
||||
setFilesLoading(stashRef)
|
||||
try {
|
||||
const fileList = await gitStashShow(folderPath, stashRef)
|
||||
setStashFiles((prev) => ({ ...prev, [stashRef]: fileList }))
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
} finally {
|
||||
setFilesLoading(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectFile(stashRef: string, file: string) {
|
||||
setSelectedFile(file)
|
||||
setSelectedStashRef(stashRef)
|
||||
setDiffLoading(true)
|
||||
try {
|
||||
const [orig, mod] = await Promise.all([
|
||||
gitShowFile(folderPath, file, stashRef + "^").catch(() => ""),
|
||||
gitShowFile(folderPath, file, stashRef).catch(() => ""),
|
||||
])
|
||||
setOriginalContent(orig)
|
||||
setModifiedContent(mod)
|
||||
} catch {
|
||||
setOriginalContent("")
|
||||
setModifiedContent("")
|
||||
} finally {
|
||||
setDiffLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApply(stashRef: string) {
|
||||
setActionLoading(true)
|
||||
try {
|
||||
await gitStashApply(folderPath, stashRef)
|
||||
toast.success(t("applySuccess"))
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDrop(stashRef: string) {
|
||||
setActionLoading(true)
|
||||
try {
|
||||
await gitStashDrop(folderPath, stashRef)
|
||||
toast.success(t("dropSuccess"))
|
||||
if (expandedStash === stashRef) {
|
||||
setExpandedStash(null)
|
||||
}
|
||||
if (selectedStashRef === stashRef) {
|
||||
setSelectedFile(null)
|
||||
setSelectedStashRef(null)
|
||||
setOriginalContent("")
|
||||
setModifiedContent("")
|
||||
}
|
||||
setStashFiles((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[stashRef]
|
||||
return next
|
||||
})
|
||||
await loadStashes()
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Render file tree nodes
|
||||
function renderNode(node: TreeNode, stashRef: string): React.ReactNode {
|
||||
if (node.kind === "dir") {
|
||||
return (
|
||||
<FileTreeFolder key={node.path} name={node.name} path={node.path}>
|
||||
{node.children.map((child) => renderNode(child, stashRef))}
|
||||
</FileTreeFolder>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu key={node.path}>
|
||||
<ContextMenuTrigger>
|
||||
<FileTreeFile
|
||||
name={node.name}
|
||||
path={node.path}
|
||||
className="gap-1 px-1.5 py-1"
|
||||
>
|
||||
<span className="flex-1 truncate text-left" title={node.path}>
|
||||
{node.name}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"w-5 shrink-0 text-right text-xs font-bold",
|
||||
statusColor(node.entry.status)
|
||||
)}
|
||||
>
|
||||
{node.entry.status.charAt(0)}
|
||||
</span>
|
||||
</FileTreeFile>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleSelectFile(stashRef, node.path)}
|
||||
>
|
||||
{t("viewDiff")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full">
|
||||
{/* Left panel: stash cards */}
|
||||
<ResizablePanel defaultSize={35} minSize={25}>
|
||||
<ScrollArea className="h-full">
|
||||
{listLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : stashes.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{t("noStashes")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{stashes.map((stash) => (
|
||||
<StashCard
|
||||
key={stash.ref_name}
|
||||
stash={stash}
|
||||
isExpanded={expandedStash === stash.ref_name}
|
||||
isLoadingFiles={filesLoading === stash.ref_name}
|
||||
actionLoading={actionLoading}
|
||||
files={stashFiles[stash.ref_name]}
|
||||
selectedFile={
|
||||
selectedStashRef === stash.ref_name ? selectedFile : null
|
||||
}
|
||||
onToggle={() => handleToggleStash(stash.ref_name)}
|
||||
onApply={() => handleApply(stash.ref_name)}
|
||||
onDrop={() => handleDrop(stash.ref_name)}
|
||||
onSelectFile={(file) =>
|
||||
handleSelectFile(stash.ref_name, file)
|
||||
}
|
||||
renderNode={(node) => renderNode(node, stash.ref_name)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
{/* Right panel: diff viewer */}
|
||||
<ResizablePanel defaultSize={65} minSize={40}>
|
||||
{diffLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : selectedFile && selectedStashRef ? (
|
||||
<DiffViewer
|
||||
original={originalContent}
|
||||
modified={modifiedContent}
|
||||
originalLabel={`${selectedStashRef}^ (${t("original")})`}
|
||||
modifiedLabel={`${selectedStashRef} (${t("modified")})`}
|
||||
language={languageFromPath(selectedFile)}
|
||||
className="h-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t("selectFile")}
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Stash Card Component ---
|
||||
|
||||
interface StashCardProps {
|
||||
stash: GitStashEntry
|
||||
isExpanded: boolean
|
||||
isLoadingFiles: boolean
|
||||
actionLoading: boolean
|
||||
files?: GitStatusEntry[]
|
||||
selectedFile: string | null
|
||||
onToggle: () => void
|
||||
onApply: () => void
|
||||
onDrop: () => void
|
||||
onSelectFile: (file: string) => void
|
||||
renderNode: (node: TreeNode) => React.ReactNode
|
||||
t: ReturnType<typeof useTranslations>
|
||||
}
|
||||
|
||||
function StashCard({
|
||||
stash,
|
||||
isExpanded,
|
||||
isLoadingFiles,
|
||||
actionLoading,
|
||||
files,
|
||||
selectedFile,
|
||||
onToggle,
|
||||
onApply,
|
||||
onDrop,
|
||||
onSelectFile,
|
||||
renderNode,
|
||||
t,
|
||||
}: StashCardProps) {
|
||||
const [confirmApplyOpen, setConfirmApplyOpen] = useState(false)
|
||||
const tree = useMemo(() => (files ? buildFileTree(files) : []), [files])
|
||||
|
||||
const defaultExpanded = useMemo(
|
||||
() => (files ? collectDirPaths(files) : new Set<string>()),
|
||||
[files]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<Collapsible open={isExpanded} onOpenChange={onToggle}>
|
||||
<div className="group rounded-lg border bg-card">
|
||||
<div className="relative flex items-center">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-t-lg px-3 py-2 text-left text-sm transition-colors group-hover:bg-muted/50"
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform",
|
||||
isExpanded && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<Archive className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{stash.ref_name}
|
||||
</span>
|
||||
<span className="truncate font-medium">
|
||||
{stash.message}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 text-[10px] text-muted-foreground/70">
|
||||
<span>{stash.branch}</span>
|
||||
<span>{stash.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1.5 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100"
|
||||
title={t("apply") as string}
|
||||
onClick={() => setConfirmApplyOpen(true)}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<ArchiveRestore className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="border-t">
|
||||
{/* File tree */}
|
||||
{isLoadingFiles ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : tree.length > 0 ? (
|
||||
<div className="px-2 pb-2">
|
||||
<FileTree
|
||||
defaultExpanded={defaultExpanded}
|
||||
selectedPath={selectedFile ?? undefined}
|
||||
onSelect={onSelectFile}
|
||||
className="border-0 bg-transparent"
|
||||
>
|
||||
{tree.map(renderNode)}
|
||||
</FileTree>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => setConfirmApplyOpen(true)}>
|
||||
{t("apply")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem variant="destructive" onClick={onDrop}>
|
||||
{t("drop")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<AlertDialog open={confirmApplyOpen} onOpenChange={setConfirmApplyOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("apply")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("confirmApply", { ref: stash.ref_name })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setConfirmApplyOpen(false)
|
||||
onApply()
|
||||
}}
|
||||
>
|
||||
{t("apply")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -827,7 +827,7 @@
|
||||
"pushCode": "دفع...",
|
||||
"newBranch": "فرع جديد...",
|
||||
"newWorktree": "Worktree جديد...",
|
||||
"stashChanges": "تخزين التغييرات في stash",
|
||||
"stashChanges": "...تخبئة التغييرات",
|
||||
"stashPop": "استرجاع stash...",
|
||||
"manageRemotes": "إدارة المستودعات البعيدة...",
|
||||
"localBranches": "الفروع المحلية ({count, plural, one {#} other {#}})",
|
||||
@@ -859,6 +859,31 @@
|
||||
"completeMerge": "إتمام الدمج",
|
||||
"abortSuccess": "تم إلغاء الدمج بنجاح",
|
||||
"completeSuccess": "تم إتمام الدمج بنجاح"
|
||||
},
|
||||
"stashDialog": {
|
||||
"title": "تخبئة التغييرات",
|
||||
"description": "حفظ التغييرات الحالية في المخبأ",
|
||||
"messageLabel": "رسالة",
|
||||
"messagePlaceholder": "رسالة التخبئة (اختياري)",
|
||||
"keepIndex": "الاحتفاظ بالفهرس (التغييرات المرحلة تبقى مرحلة)",
|
||||
"cancel": "إلغاء",
|
||||
"stash": "تخبئة",
|
||||
"success": "تم تخبئة التغييرات",
|
||||
"error": "فشل في تخبئة التغييرات"
|
||||
},
|
||||
"unstashDialog": {
|
||||
"title": "تطبيق المخبأ",
|
||||
"noStashes": "لا توجد تخبئات",
|
||||
"selectFile": "اختر ملفاً لعرض الفرق",
|
||||
"viewDiff": "عرض الفرق",
|
||||
"original": "الأصلي",
|
||||
"modified": "المعدل",
|
||||
"apply": "تطبيق",
|
||||
"drop": "حذف",
|
||||
"applySuccess": "تم تطبيق التخبئة",
|
||||
"dropSuccess": "تم حذف التخبئة",
|
||||
"confirmApply": "تطبيق التخبئة {ref} على دليل العمل؟",
|
||||
"cancel": "إلغاء"
|
||||
}
|
||||
},
|
||||
"commitDialog": {
|
||||
|
||||
@@ -827,7 +827,7 @@
|
||||
"pushCode": "Hochladen...",
|
||||
"newBranch": "Neuer Branch...",
|
||||
"newWorktree": "Neuer Worktree...",
|
||||
"stashChanges": "Änderungen stashen",
|
||||
"stashChanges": "Änderungen stashen...",
|
||||
"stashPop": "Stash anwenden...",
|
||||
"manageRemotes": "Remotes verwalten...",
|
||||
"localBranches": "Lokale Branches ({count, plural, one {#} other {#}})",
|
||||
@@ -859,6 +859,31 @@
|
||||
"completeMerge": "Merge abschließen",
|
||||
"abortSuccess": "Merge erfolgreich abgebrochen",
|
||||
"completeSuccess": "Merge erfolgreich abgeschlossen"
|
||||
},
|
||||
"stashDialog": {
|
||||
"title": "Änderungen stashen",
|
||||
"description": "Aktuelle Änderungen im Stash speichern",
|
||||
"messageLabel": "Nachricht",
|
||||
"messagePlaceholder": "Stash-Nachricht (optional)",
|
||||
"keepIndex": "Index beibehalten (gestagete Änderungen bleiben erhalten)",
|
||||
"cancel": "Abbrechen",
|
||||
"stash": "Stashen",
|
||||
"success": "Änderungen wurden gestasht",
|
||||
"error": "Stash fehlgeschlagen"
|
||||
},
|
||||
"unstashDialog": {
|
||||
"title": "Stash anwenden",
|
||||
"noStashes": "Keine Stashes vorhanden",
|
||||
"selectFile": "Datei auswählen um Diff anzuzeigen",
|
||||
"viewDiff": "Diff anzeigen",
|
||||
"original": "Original",
|
||||
"modified": "Geändert",
|
||||
"apply": "Anwenden",
|
||||
"drop": "Löschen",
|
||||
"applySuccess": "Stash angewendet",
|
||||
"dropSuccess": "Stash gelöscht",
|
||||
"confirmApply": "Stash {ref} auf das Arbeitsverzeichnis anwenden?",
|
||||
"cancel": "Abbrechen"
|
||||
}
|
||||
},
|
||||
"commitDialog": {
|
||||
|
||||
@@ -827,8 +827,8 @@
|
||||
"pushCode": "Push...",
|
||||
"newBranch": "New branch...",
|
||||
"newWorktree": "New worktree...",
|
||||
"stashChanges": "Stash changes",
|
||||
"stashPop": "Pop stash...",
|
||||
"stashChanges": "Stash changes...",
|
||||
"stashPop": "Unstash...",
|
||||
"manageRemotes": "Manage Remotes...",
|
||||
"localBranches": "Local branches ({count, plural, one {#} other {#}})",
|
||||
"noLocalBranches": "No local branches",
|
||||
@@ -859,6 +859,31 @@
|
||||
"completeMerge": "Complete Merge",
|
||||
"abortSuccess": "Merge aborted successfully",
|
||||
"completeSuccess": "Merge completed successfully"
|
||||
},
|
||||
"stashDialog": {
|
||||
"title": "Stash Changes",
|
||||
"description": "Save your current changes to a stash",
|
||||
"messageLabel": "Message",
|
||||
"messagePlaceholder": "Stash message (optional)",
|
||||
"keepIndex": "Keep index (staged changes remain staged)",
|
||||
"cancel": "Cancel",
|
||||
"stash": "Stash",
|
||||
"success": "Changes stashed successfully",
|
||||
"error": "Failed to stash changes"
|
||||
},
|
||||
"unstashDialog": {
|
||||
"title": "Unstash Changes",
|
||||
"noStashes": "No stashes found",
|
||||
"selectFile": "Select a file to view diff",
|
||||
"viewDiff": "View Diff",
|
||||
"original": "Original",
|
||||
"modified": "Modified",
|
||||
"apply": "Apply",
|
||||
"drop": "Drop",
|
||||
"applySuccess": "Stash applied successfully",
|
||||
"dropSuccess": "Stash dropped",
|
||||
"confirmApply": "Apply stash {ref} to working directory?",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
},
|
||||
"commitDialog": {
|
||||
|
||||
@@ -827,7 +827,7 @@
|
||||
"pushCode": "Enviar...",
|
||||
"newBranch": "Nueva rama...",
|
||||
"newWorktree": "Nuevo worktree...",
|
||||
"stashChanges": "Guardar cambios en stash",
|
||||
"stashChanges": "Guardar cambios...",
|
||||
"stashPop": "Aplicar stash...",
|
||||
"manageRemotes": "Gestionar remotos...",
|
||||
"localBranches": "Ramas locales ({count, plural, one {#} other {#}})",
|
||||
@@ -859,6 +859,31 @@
|
||||
"completeMerge": "Completar fusión",
|
||||
"abortSuccess": "Fusión abortada correctamente",
|
||||
"completeSuccess": "Fusión completada correctamente"
|
||||
},
|
||||
"stashDialog": {
|
||||
"title": "Guardar cambios en stash",
|
||||
"description": "Guardar los cambios actuales en el stash",
|
||||
"messageLabel": "Mensaje",
|
||||
"messagePlaceholder": "Mensaje del stash (opcional)",
|
||||
"keepIndex": "Mantener índice (los cambios preparados permanecen preparados)",
|
||||
"cancel": "Cancelar",
|
||||
"stash": "Guardar",
|
||||
"success": "Cambios guardados en stash",
|
||||
"error": "Error al guardar en stash"
|
||||
},
|
||||
"unstashDialog": {
|
||||
"title": "Aplicar stash",
|
||||
"noStashes": "No hay stashes",
|
||||
"selectFile": "Selecciona un archivo para ver diferencias",
|
||||
"viewDiff": "Ver diferencias",
|
||||
"original": "Original",
|
||||
"modified": "Modificado",
|
||||
"apply": "Aplicar",
|
||||
"drop": "Eliminar",
|
||||
"applySuccess": "Stash aplicado",
|
||||
"dropSuccess": "Stash eliminado",
|
||||
"confirmApply": "¿Aplicar stash {ref} al directorio de trabajo?",
|
||||
"cancel": "Cancelar"
|
||||
}
|
||||
},
|
||||
"commitDialog": {
|
||||
|
||||
@@ -827,7 +827,7 @@
|
||||
"pushCode": "Pousser...",
|
||||
"newBranch": "Nouvelle branche...",
|
||||
"newWorktree": "Nouveau worktree...",
|
||||
"stashChanges": "Stash des changements",
|
||||
"stashChanges": "Remiser les changements...",
|
||||
"stashPop": "Appliquer le stash...",
|
||||
"manageRemotes": "Gérer les dépôts distants...",
|
||||
"localBranches": "Branches locales ({count, plural, one {#} other {#}})",
|
||||
@@ -859,6 +859,31 @@
|
||||
"completeMerge": "Terminer la fusion",
|
||||
"abortSuccess": "Fusion abandonnée avec succès",
|
||||
"completeSuccess": "Fusion terminée avec succès"
|
||||
},
|
||||
"stashDialog": {
|
||||
"title": "Remiser les changements",
|
||||
"description": "Sauvegarder les changements actuels dans la remise",
|
||||
"messageLabel": "Message",
|
||||
"messagePlaceholder": "Message de remise (optionnel)",
|
||||
"keepIndex": "Conserver l'index (les changements indexés restent indexés)",
|
||||
"cancel": "Annuler",
|
||||
"stash": "Remiser",
|
||||
"success": "Changements remisés",
|
||||
"error": "Échec de la remise"
|
||||
},
|
||||
"unstashDialog": {
|
||||
"title": "Appliquer la remise",
|
||||
"noStashes": "Aucune remise trouvée",
|
||||
"selectFile": "Sélectionner un fichier pour voir le diff",
|
||||
"viewDiff": "Voir le diff",
|
||||
"original": "Original",
|
||||
"modified": "Modifié",
|
||||
"apply": "Appliquer",
|
||||
"drop": "Supprimer",
|
||||
"applySuccess": "Remise appliquée",
|
||||
"dropSuccess": "Remise supprimée",
|
||||
"confirmApply": "Appliquer la remise {ref} au répertoire de travail ?",
|
||||
"cancel": "Annuler"
|
||||
}
|
||||
},
|
||||
"commitDialog": {
|
||||
|
||||
@@ -827,7 +827,7 @@
|
||||
"pushCode": "プッシュ...",
|
||||
"newBranch": "新規ブランチ...",
|
||||
"newWorktree": "新規ワークツリー...",
|
||||
"stashChanges": "変更を stash",
|
||||
"stashChanges": "スタッシュ...",
|
||||
"stashPop": "stash を pop...",
|
||||
"manageRemotes": "リモート管理...",
|
||||
"localBranches": "ローカルブランチ ({count, plural, one {#} other {#}})",
|
||||
@@ -859,6 +859,31 @@
|
||||
"completeMerge": "マージ完了",
|
||||
"abortSuccess": "マージが中止されました",
|
||||
"completeSuccess": "マージが完了しました"
|
||||
},
|
||||
"stashDialog": {
|
||||
"title": "変更をスタッシュ",
|
||||
"description": "現在の変更をスタッシュに保存",
|
||||
"messageLabel": "メッセージ",
|
||||
"messagePlaceholder": "スタッシュメッセージ(任意)",
|
||||
"keepIndex": "インデックスを保持(ステージ済みの変更はそのまま)",
|
||||
"cancel": "キャンセル",
|
||||
"stash": "スタッシュ",
|
||||
"success": "変更がスタッシュされました",
|
||||
"error": "スタッシュに失敗しました"
|
||||
},
|
||||
"unstashDialog": {
|
||||
"title": "スタッシュを適用",
|
||||
"noStashes": "スタッシュがありません",
|
||||
"selectFile": "ファイルを選択して差分を表示",
|
||||
"viewDiff": "差分を表示",
|
||||
"original": "元",
|
||||
"modified": "変更後",
|
||||
"apply": "適用",
|
||||
"drop": "削除",
|
||||
"applySuccess": "スタッシュを適用しました",
|
||||
"dropSuccess": "スタッシュを削除しました",
|
||||
"confirmApply": "スタッシュ {ref} を作業ディレクトリに適用しますか?",
|
||||
"cancel": "キャンセル"
|
||||
}
|
||||
},
|
||||
"commitDialog": {
|
||||
|
||||
@@ -827,7 +827,7 @@
|
||||
"pushCode": "푸시...",
|
||||
"newBranch": "새 브랜치...",
|
||||
"newWorktree": "새 워크트리...",
|
||||
"stashChanges": "변경 사항 stash",
|
||||
"stashChanges": "스태시...",
|
||||
"stashPop": "stash pop...",
|
||||
"manageRemotes": "원격 관리...",
|
||||
"localBranches": "로컬 브랜치 ({count, plural, one {#} other {#}})",
|
||||
@@ -859,6 +859,31 @@
|
||||
"completeMerge": "병합 완료",
|
||||
"abortSuccess": "병합이 중단되었습니다",
|
||||
"completeSuccess": "병합이 완료되었습니다"
|
||||
},
|
||||
"stashDialog": {
|
||||
"title": "변경 사항 스태시",
|
||||
"description": "현재 변경 사항을 스태시에 저장",
|
||||
"messageLabel": "메시지",
|
||||
"messagePlaceholder": "스태시 메시지 (선택사항)",
|
||||
"keepIndex": "인덱스 유지 (스테이지된 변경 사항 유지)",
|
||||
"cancel": "취소",
|
||||
"stash": "스태시",
|
||||
"success": "변경 사항이 스태시되었습니다",
|
||||
"error": "스태시 실패"
|
||||
},
|
||||
"unstashDialog": {
|
||||
"title": "스태시 적용",
|
||||
"noStashes": "스태시가 없습니다",
|
||||
"selectFile": "파일을 선택하여 차이 보기",
|
||||
"viewDiff": "차이 보기",
|
||||
"original": "원본",
|
||||
"modified": "수정됨",
|
||||
"apply": "적용",
|
||||
"drop": "삭제",
|
||||
"applySuccess": "스태시가 적용되었습니다",
|
||||
"dropSuccess": "스태시가 삭제되었습니다",
|
||||
"confirmApply": "스태시 {ref}을(를) 작업 디렉토리에 적용하시겠습니까?",
|
||||
"cancel": "취소"
|
||||
}
|
||||
},
|
||||
"commitDialog": {
|
||||
|
||||
@@ -827,7 +827,7 @@
|
||||
"pushCode": "Enviar...",
|
||||
"newBranch": "Nova branch...",
|
||||
"newWorktree": "Novo worktree...",
|
||||
"stashChanges": "Fazer stash das alterações",
|
||||
"stashChanges": "Guardar alterações...",
|
||||
"stashPop": "Aplicar stash...",
|
||||
"manageRemotes": "Gerenciar remotos...",
|
||||
"localBranches": "Branches locais ({count, plural, one {#} other {#}})",
|
||||
@@ -859,6 +859,31 @@
|
||||
"completeMerge": "Concluir merge",
|
||||
"abortSuccess": "Merge abortado com sucesso",
|
||||
"completeSuccess": "Merge concluído com sucesso"
|
||||
},
|
||||
"stashDialog": {
|
||||
"title": "Guardar alterações no stash",
|
||||
"description": "Guardar as alterações atuais no stash",
|
||||
"messageLabel": "Mensagem",
|
||||
"messagePlaceholder": "Mensagem do stash (opcional)",
|
||||
"keepIndex": "Manter índice (alterações preparadas permanecem preparadas)",
|
||||
"cancel": "Cancelar",
|
||||
"stash": "Guardar",
|
||||
"success": "Alterações guardadas no stash",
|
||||
"error": "Erro ao guardar no stash"
|
||||
},
|
||||
"unstashDialog": {
|
||||
"title": "Aplicar stash",
|
||||
"noStashes": "Nenhum stash encontrado",
|
||||
"selectFile": "Selecione um ficheiro para ver diferenças",
|
||||
"viewDiff": "Ver diferenças",
|
||||
"original": "Original",
|
||||
"modified": "Modificado",
|
||||
"apply": "Aplicar",
|
||||
"drop": "Eliminar",
|
||||
"applySuccess": "Stash aplicado",
|
||||
"dropSuccess": "Stash eliminado",
|
||||
"confirmApply": "Aplicar stash {ref} ao diretório de trabalho?",
|
||||
"cancel": "Cancelar"
|
||||
}
|
||||
},
|
||||
"commitDialog": {
|
||||
|
||||
@@ -827,7 +827,7 @@
|
||||
"pushCode": "推送...",
|
||||
"newBranch": "新建分支...",
|
||||
"newWorktree": "新建工作树...",
|
||||
"stashChanges": "贮藏更改",
|
||||
"stashChanges": "贮藏更改...",
|
||||
"stashPop": "取消贮藏...",
|
||||
"manageRemotes": "管理远程...",
|
||||
"localBranches": "本地分支 ({count})",
|
||||
@@ -859,6 +859,31 @@
|
||||
"completeMerge": "完成合并",
|
||||
"abortSuccess": "合并已中止",
|
||||
"completeSuccess": "合并完成"
|
||||
},
|
||||
"stashDialog": {
|
||||
"title": "贮藏更改",
|
||||
"description": "将当前更改保存到贮藏区",
|
||||
"messageLabel": "消息",
|
||||
"messagePlaceholder": "贮藏消息(可选)",
|
||||
"keepIndex": "保留暂存区(已暂存的更改保持不变)",
|
||||
"cancel": "取消",
|
||||
"stash": "贮藏",
|
||||
"success": "更改已贮藏",
|
||||
"error": "贮藏更改失败"
|
||||
},
|
||||
"unstashDialog": {
|
||||
"title": "取消贮藏",
|
||||
"noStashes": "没有贮藏记录",
|
||||
"selectFile": "选择文件查看差异",
|
||||
"viewDiff": "查看差异",
|
||||
"original": "原始",
|
||||
"modified": "修改后",
|
||||
"apply": "应用",
|
||||
"drop": "删除",
|
||||
"applySuccess": "贮藏已应用",
|
||||
"dropSuccess": "贮藏已删除",
|
||||
"confirmApply": "将贮藏 {ref} 应用到工作目录?",
|
||||
"cancel": "取消"
|
||||
}
|
||||
},
|
||||
"commitDialog": {
|
||||
|
||||
@@ -827,7 +827,7 @@
|
||||
"pushCode": "推送...",
|
||||
"newBranch": "新增分支...",
|
||||
"newWorktree": "新增工作樹...",
|
||||
"stashChanges": "暫存變更",
|
||||
"stashChanges": "貯藏更改...",
|
||||
"stashPop": "取消暫存...",
|
||||
"manageRemotes": "管理遠端...",
|
||||
"localBranches": "本地分支 ({count})",
|
||||
@@ -859,6 +859,31 @@
|
||||
"completeMerge": "完成合併",
|
||||
"abortSuccess": "合併已中止",
|
||||
"completeSuccess": "合併完成"
|
||||
},
|
||||
"stashDialog": {
|
||||
"title": "貯藏更改",
|
||||
"description": "將當前更改保存到貯藏區",
|
||||
"messageLabel": "訊息",
|
||||
"messagePlaceholder": "貯藏訊息(可選)",
|
||||
"keepIndex": "保留暫存區(已暫存的更改保持不變)",
|
||||
"cancel": "取消",
|
||||
"stash": "貯藏",
|
||||
"success": "更改已貯藏",
|
||||
"error": "貯藏更改失敗"
|
||||
},
|
||||
"unstashDialog": {
|
||||
"title": "取消貯藏",
|
||||
"noStashes": "沒有貯藏記錄",
|
||||
"selectFile": "選擇檔案查看差異",
|
||||
"viewDiff": "查看差異",
|
||||
"original": "原始",
|
||||
"modified": "修改後",
|
||||
"apply": "套用",
|
||||
"drop": "刪除",
|
||||
"applySuccess": "貯藏已套用",
|
||||
"dropSuccess": "貯藏已刪除",
|
||||
"confirmApply": "將貯藏 {ref} 套用到工作目錄?",
|
||||
"cancel": "取消"
|
||||
}
|
||||
},
|
||||
"commitDialog": {
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
GitConflictFileVersions,
|
||||
GitCommitResult,
|
||||
GitRemote,
|
||||
GitStashEntry,
|
||||
PreflightResult,
|
||||
FolderCommand,
|
||||
TerminalInfo,
|
||||
@@ -582,12 +583,56 @@ export async function openMergeWindow(
|
||||
})
|
||||
}
|
||||
|
||||
export async function gitStash(path: string): Promise<string> {
|
||||
return invoke("git_stash", { path })
|
||||
export async function openStashWindow(folderId: number): Promise<void> {
|
||||
return invoke("open_stash_window", { folderId })
|
||||
}
|
||||
|
||||
export async function gitStashPop(path: string): Promise<string> {
|
||||
return invoke("git_stash_pop", { path })
|
||||
export async function gitStashPush(
|
||||
path: string,
|
||||
message?: string,
|
||||
keepIndex?: boolean
|
||||
): Promise<string> {
|
||||
return invoke("git_stash_push", {
|
||||
path,
|
||||
message: message ?? null,
|
||||
keepIndex: keepIndex ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
export async function gitStashPop(
|
||||
path: string,
|
||||
stashRef?: string
|
||||
): Promise<string> {
|
||||
return invoke("git_stash_pop", { path, stashRef: stashRef ?? null })
|
||||
}
|
||||
|
||||
export async function gitStashList(path: string): Promise<GitStashEntry[]> {
|
||||
return invoke("git_stash_list", { path })
|
||||
}
|
||||
|
||||
export async function gitStashApply(
|
||||
path: string,
|
||||
stashRef: string
|
||||
): Promise<string> {
|
||||
return invoke("git_stash_apply", { path, stashRef })
|
||||
}
|
||||
|
||||
export async function gitStashDrop(
|
||||
path: string,
|
||||
stashRef: string
|
||||
): Promise<string> {
|
||||
return invoke("git_stash_drop", { path, stashRef })
|
||||
}
|
||||
|
||||
export async function gitStashClear(path: string): Promise<string> {
|
||||
return invoke("git_stash_clear", { path })
|
||||
}
|
||||
|
||||
export async function gitStashShow(
|
||||
path: string,
|
||||
stashRef: string
|
||||
): Promise<GitStatusEntry[]> {
|
||||
return invoke("git_stash_show", { path, stashRef })
|
||||
}
|
||||
|
||||
export async function gitListRemotes(path: string): Promise<GitRemote[]> {
|
||||
|
||||
@@ -709,6 +709,14 @@ export interface GitRemote {
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface GitStashEntry {
|
||||
index: number
|
||||
message: string
|
||||
branch: string
|
||||
date: string
|
||||
ref_name: string
|
||||
}
|
||||
|
||||
export type FileTreeNode =
|
||||
| { kind: "file"; name: string; path: string }
|
||||
| { kind: "dir"; name: string; path: string; children: FileTreeNode[] }
|
||||
|
||||
Reference in New Issue
Block a user