Merge remote-tracking branch 'origin/main'

This commit is contained in:
itpkcn@gmail.com
2026-03-21 10:51:22 +08:00
19 changed files with 448 additions and 75 deletions

View File

@@ -1563,38 +1563,44 @@ export function FileWorkspacePanel() {
</div>
)}
<div className="flex-1 min-h-0">
<MonacoEditor
beforeMount={defineMonacoThemes}
onMount={handleEditorMount}
path={buildMonacoModelPath(activeFileTab.path, activeFileTab.id)}
value={renderedContent}
onChange={(value) => {
if (!isFileTab) return
updateActiveFileContent(value ?? "")
}}
language={activeFileTab.language}
theme={editorTheme}
loading={
<div className="h-full flex items-center justify-center text-xs text-muted-foreground">
{t("loadingEditor")}
</div>
}
options={{
readOnly: !canEdit,
minimap: { enabled: false },
automaticLayout: true,
fontSize: 13,
lineNumbersMinChars,
lineDecorationsWidth: 10,
wordWrap: "off",
scrollBeyondLastLine: false,
scrollBeyondLastColumn: 8,
renderLineHighlight: "line",
scrollbar: {
horizontal: "auto",
},
}}
/>
{activeFileTab.loading ? (
<div className="h-full flex items-center justify-center text-xs text-muted-foreground">
{t("loadingEditor")}
</div>
) : (
<MonacoEditor
beforeMount={defineMonacoThemes}
onMount={handleEditorMount}
path={buildMonacoModelPath(activeFileTab.path, activeFileTab.id)}
value={renderedContent}
onChange={(value) => {
if (!isFileTab) return
updateActiveFileContent(value ?? "")
}}
language={activeFileTab.language}
theme={editorTheme}
loading={
<div className="h-full flex items-center justify-center text-xs text-muted-foreground">
{t("loadingEditor")}
</div>
}
options={{
readOnly: !canEdit,
minimap: { enabled: false },
automaticLayout: true,
fontSize: 13,
lineNumbersMinChars,
lineDecorationsWidth: 10,
wordWrap: "off",
scrollBeyondLastLine: false,
scrollBeyondLastColumn: 8,
renderLineHighlight: "line",
scrollbar: {
horizontal: "auto",
},
}}
/>
)}
</div>
</div>
</div>

View File

@@ -409,8 +409,10 @@ interface RenderNodeProps {
gitEnabled: boolean
gitStatusByPath: ReadonlyMap<string, string>
gitChangedDirPaths: ReadonlySet<string>
untrackedDirPaths: ReadonlySet<string>
gitignoreIgnoredPaths: ReadonlySet<string>
ancestorGitignoreIgnored: boolean
ancestorUntracked: boolean
onOpenFilePreview: (path: string) => void
onOpenFileDiff: (path: string) => void
onOpenDirDiff: (path: string) => void
@@ -433,8 +435,10 @@ function RenderNode({
gitEnabled,
gitStatusByPath,
gitChangedDirPaths,
untrackedDirPaths,
gitignoreIgnoredPaths,
ancestorGitignoreIgnored,
ancestorUntracked,
onOpenFilePreview,
onOpenFileDiff,
onOpenDirDiff,
@@ -465,7 +469,8 @@ function RenderNode({
})()
if (node.kind === "file") {
const gitStatusCode = gitStatusByPath.get(node.path)
const gitStatusCode =
gitStatusByPath.get(node.path) ?? (ancestorUntracked ? "??" : undefined)
const absolutePath = joinFsPath(workspacePath, node.path)
const dirPath = parentDir(absolutePath)
const isGitMenuDisabled = !gitEnabled || isGitignoreIgnored
@@ -599,7 +604,11 @@ function RenderNode({
}
const absolutePath = joinFsPath(workspacePath, node.path)
const dirHasChanges = !isGitignoreIgnored && gitChangedDirPaths.has(node.path)
const isThisDirUntracked =
ancestorUntracked || untrackedDirPaths.has(node.path)
const dirHasChanges =
!isGitignoreIgnored &&
(gitChangedDirPaths.has(node.path) || isThisDirUntracked)
const isGitMenuDisabled = !gitEnabled || isGitignoreIgnored
const shouldRenderChildren = expandedPaths.has(node.path)
@@ -638,8 +647,10 @@ function RenderNode({
gitEnabled={gitEnabled}
gitStatusByPath={gitStatusByPath}
gitChangedDirPaths={gitChangedDirPaths}
untrackedDirPaths={untrackedDirPaths}
gitignoreIgnoredPaths={gitignoreIgnoredPaths}
ancestorGitignoreIgnored={isGitignoreIgnored}
ancestorUntracked={isThisDirUntracked}
onOpenFilePreview={onOpenFilePreview}
onOpenFileDiff={onOpenFileDiff}
onOpenDirDiff={onOpenDirDiff}
@@ -908,7 +919,10 @@ export function FileTreeTab() {
(entries: { file: string; status: string }[]) => {
const nextStatusByPath = new Map<string, string>()
for (const entry of entries) {
const normalizedPath = normalizeGitStatusPath(entry.file)
const raw = normalizeGitStatusPath(entry.file)
if (!raw) continue
// Strip trailing slash (directory entries from -unormal)
const normalizedPath = raw.replace(/\/+$/, "")
if (!normalizedPath) continue
nextStatusByPath.set(normalizedPath, entry.status)
}
@@ -1182,6 +1196,20 @@ export function FileTreeTab() {
return dirs
}, [gitStatusByPath])
// Directories that are entirely untracked (from git status -unormal)
const untrackedDirPaths = useMemo(() => {
const dirs = new Set<string>()
for (const [path, status] of gitStatusByPath.entries()) {
if (status.trim() === "??") {
// Check if this path is a directory in the file tree
if (dirChildrenByPath.has(path)) {
dirs.add(path)
}
}
}
return dirs
}, [gitStatusByPath, dirChildrenByPath])
const handleTreeSelect = useCallback(
(path: string) => {
if (!filePathSet.has(path)) return
@@ -2163,8 +2191,10 @@ export function FileTreeTab() {
gitEnabled={gitEnabled}
gitStatusByPath={gitStatusByPath}
gitChangedDirPaths={gitChangedDirPaths}
untrackedDirPaths={untrackedDirPaths}
gitignoreIgnoredPaths={gitignoreIgnoredPaths}
ancestorGitignoreIgnored={false}
ancestorUntracked={false}
onOpenFilePreview={(path) => {
void openFilePreview(path)
}}

View File

@@ -38,6 +38,7 @@ import { useAuxPanelContext } from "@/contexts/aux-panel-context"
import { useFolderContext } from "@/contexts/folder-context"
import { useWorkspaceContext } from "@/contexts/workspace-context"
import {
deleteFileTreeEntry,
gitDiff,
gitAddFiles,
gitRollbackFile,
@@ -80,7 +81,11 @@ interface GitActionTarget {
name: string
}
type DirectoryGitAction = "add" | "rollback"
type DirectoryGitAction =
| "add"
| "rollback"
| "delete-tracked"
| "delete-untracked"
interface DirectoryGitCandidateEntry {
path: string
@@ -142,7 +147,7 @@ function normalizePathSegments(path: string): string[] {
}
function normalizeGitStatusPath(path: string): string {
const normalized = path.trim()
const normalized = path.trim().replace(/\/+$/, "")
const renameSeparator = " -> "
const renameIndex = normalized.lastIndexOf(renameSeparator)
if (renameIndex < 0) return normalized
@@ -188,6 +193,10 @@ function scopeGitStatusEntriesForDirectory(
)
}
function isDeleteAction(action: DirectoryGitAction): boolean {
return action === "delete-tracked" || action === "delete-untracked"
}
function filterDirectoryGitCandidates(
entries: DirectoryGitCandidateEntry[],
action: DirectoryGitAction
@@ -196,6 +205,20 @@ function filterDirectoryGitCandidates(
return entries.filter((entry) => entry.status.trim().length > 0)
}
if (action === "delete-tracked") {
return entries.filter((entry) => {
const fileState = classifyGitFileState(entry.status)
return fileState !== null && fileState !== "untracked"
})
}
if (action === "delete-untracked") {
return entries.filter((entry) => {
const fileState = classifyGitFileState(entry.status)
return fileState === "untracked"
})
}
return entries.filter((entry) => {
const fileState = classifyGitFileState(entry.status)
return fileState !== "untracked"
@@ -446,6 +469,8 @@ export function GitChangesTab() {
null
)
const [rollingBack, setRollingBack] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<GitActionTarget | null>(null)
const [deleting, setDeleting] = useState(false)
const [directoryGitActionType, setDirectoryGitActionType] =
useState<DirectoryGitAction | null>(null)
const [directoryGitActionTarget, setDirectoryGitActionTarget] =
@@ -726,7 +751,9 @@ export function GitChangesTab() {
toast.info(
action === "add"
? t("toasts.noAddableFilesInDir")
: t("toasts.noRollbackFilesInDir")
: isDeleteAction(action)
? t("toasts.noDeletableFilesInDir")
: t("toasts.noRollbackFilesInDir")
)
return
}
@@ -793,6 +820,37 @@ export function GitChangesTab() {
}
}, [fetchChanges, folder?.path, rollbackTarget, t])
const handleRequestDelete = useCallback(
(target: GitActionTarget, scope: "tracked" | "untracked") => {
if (target.kind === "dir") {
void openDirectoryGitActionDialog(
scope === "tracked" ? "delete-tracked" : "delete-untracked",
target
)
return
}
setDeleteTarget(target)
},
[openDirectoryGitActionDialog]
)
const handleDeleteConfirm = useCallback(async () => {
if (!folder?.path || !deleteTarget) return
setDeleting(true)
try {
await deleteFileTreeEntry(folder.path, deleteTarget.path)
toast.success(t("toasts.deleted", { name: deleteTarget.name }))
setDeleteTarget(null)
await fetchChanges({ inline: true })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
toast.error(t("toasts.deleteFailed"), { description: message })
} finally {
setDeleting(false)
}
}, [deleteTarget, fetchChanges, folder?.path, t])
const directoryGitAllFilePaths = useMemo(
() => directoryGitCandidates.map((entry) => entry.path),
[directoryGitCandidates]
@@ -848,6 +906,15 @@ export function GitChangesTab() {
count: selectedPaths.length,
})
)
} else if (isDeleteAction(directoryGitActionType)) {
for (const filePath of selectedPaths) {
await deleteFileTreeEntry(folder.path, filePath)
}
toast.success(
t("toasts.deletedFiles", {
count: selectedPaths.length,
})
)
} else {
for (const filePath of selectedPaths) {
await gitRollbackFile(folder.path, filePath)
@@ -867,7 +934,9 @@ export function GitChangesTab() {
toast.error(
directoryGitActionType === "add"
? t("toasts.addToVcsFailed")
: t("toasts.rollbackFailed"),
: isDeleteAction(directoryGitActionType)
? t("toasts.deleteFailed")
: t("toasts.rollbackFailed"),
{
description: message,
}
@@ -941,6 +1010,14 @@ export function GitChangesTab() {
>
{t("actions.addToVcs")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleRequestDelete(target, "tracked")
}}
variant="destructive"
>
{t("actions.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
@@ -1021,6 +1098,14 @@ export function GitChangesTab() {
>
{t("actions.addToVcs")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleRequestDelete(target, "tracked")
}}
variant="destructive"
>
{t("actions.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
@@ -1028,6 +1113,7 @@ export function GitChangesTab() {
[
handleOpenCommitWindow,
handleAddToVcs,
handleRequestDelete,
handleRequestRollback,
openFilePreview,
openWorkingTreeDiff,
@@ -1088,6 +1174,14 @@ export function GitChangesTab() {
>
{t("actions.addToVcs")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleRequestDelete(target, "untracked")
}}
variant="destructive"
>
{t("actions.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
@@ -1158,6 +1252,14 @@ export function GitChangesTab() {
>
{t("actions.addToVcs")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleRequestDelete(target, "untracked")
}}
variant="destructive"
>
{t("actions.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
@@ -1165,6 +1267,7 @@ export function GitChangesTab() {
[
handleOpenCommitWindow,
handleAddToVcs,
handleRequestDelete,
handleRequestRollback,
openFilePreview,
openWorkingTreeDiff,
@@ -1290,6 +1393,21 @@ export function GitChangesTab() {
>
{t("actions.addToVcs")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleRequestDelete(
{
kind: "dir",
path: "",
name: folderName,
},
"tracked"
)
}}
variant="destructive"
>
{t("actions.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</FileTree>
@@ -1383,6 +1501,21 @@ export function GitChangesTab() {
>
{t("actions.addToVcs")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleRequestDelete(
{
kind: "dir",
path: "",
name: folderName,
},
"untracked"
)
}}
variant="destructive"
>
{t("actions.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</FileTree>
@@ -1404,7 +1537,10 @@ export function GitChangesTab() {
<DialogTitle>
{directoryGitActionType === "add"
? t("actions.addToVcs")
: t("actions.rollback")}
: directoryGitActionType &&
isDeleteAction(directoryGitActionType)
? t("actions.delete")
: t("actions.rollback")}
</DialogTitle>
<DialogDescription>
{directoryGitActionTarget
@@ -1412,9 +1548,14 @@ export function GitChangesTab() {
? t("directoryDialog.descriptionAdd", {
path: directoryGitActionTarget.path,
})
: t("directoryDialog.descriptionRollback", {
path: directoryGitActionTarget.path,
})
: directoryGitActionType &&
isDeleteAction(directoryGitActionType)
? t("directoryDialog.descriptionDelete", {
path: directoryGitActionTarget.path,
})
: t("directoryDialog.descriptionRollback", {
path: directoryGitActionTarget.path,
})
: t("directoryDialog.descriptionFallback")}
</DialogDescription>
</DialogHeader>
@@ -1474,9 +1615,11 @@ export function GitChangesTab() {
<span className="flex-1 truncate" title={entry.path}>
{entry.path}
</span>
<span className="shrink-0 text-muted-foreground">
{entry.status}
</span>
{entry.status !== UNTRACKED_STATUS && (
<span className="shrink-0 text-muted-foreground">
{entry.status}
</span>
)}
</button>
)
})}
@@ -1499,7 +1642,9 @@ export function GitChangesTab() {
<Button
type="button"
variant={
directoryGitActionType === "rollback"
directoryGitActionType === "rollback" ||
(directoryGitActionType &&
isDeleteAction(directoryGitActionType))
? "destructive"
: "default"
}
@@ -1514,7 +1659,10 @@ export function GitChangesTab() {
>
{directoryGitActionType === "add"
? t("actions.addToVcs")
: t("actions.rollback")}
: directoryGitActionType &&
isDeleteAction(directoryGitActionType)
? t("actions.delete")
: t("actions.rollback")}
</Button>
</DialogFooter>
</div>
@@ -1559,6 +1707,45 @@ export function GitChangesTab() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => {
if (open) return
setDeleteTarget(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteConfirm.title")}</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget
? t("deleteConfirm.descriptionWithTarget", {
kind:
deleteTarget.kind === "dir"
? t("deleteConfirm.kindDirectory")
: t("deleteConfirm.kindFile"),
name: deleteTarget.name,
})
: t("deleteConfirm.descriptionFallback")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{tCommon("cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={() => {
void handleDeleteConfirm()
}}
>
{t("actions.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}