add gitignore

This commit is contained in:
Lenrui committed 2026-11-06 00:18:31 -08:00
1 parent 0d1ff65479
commit f2c44dbc0d
8 files changed
+839 -77

No files matched your search

+361 -4
View File
@@ -117,8 +117,9 @@ from textual.screen import ModalScreen # noqa: E402
from textual.theme import Theme # noqa: E402
from textual.widgets import ( # noqa: E402
Button, Checkbox, ContentSwitcher, DirectoryTree, Footer, Input, Label, ListItem, ListView,
RichLog, Select, Static, TabbedContent, TabPane, TextArea, Tree,
OptionList, RichLog, Select, Static, TabbedContent, TabPane, TextArea, Tree,
)
from textual.widgets.option_list import Option # noqa: E402
# --------------------------------------------------------------------------
# Colours (GitHub Desktop dark-ish)
@@ -442,6 +443,10 @@ class Git:
def file_diff(self, fc: FileChange, has_head: bool) -> str:
if fc.kind == "?":
return self._untracked_diff(fc.path)
if fc.kind == "D" and has_head and os.path.exists(os.path.join(self.path, fc.path)):
# un-tracked but still on disk: show what leaves the repository
r = self.run("diff", "--cached", "--no-ext-diff", "HEAD", "--", fc.path, log=False)
return r.out if r.ok else r.message
args = ["diff", "HEAD" if has_head else EMPTY_TREE, "--no-ext-diff", "-M", "--"]
if fc.orig:
args.append(fc.orig)
@@ -947,6 +952,69 @@ def existing_folder(path: str) -> str:
return path if path and os.path.isdir(path) else os.path.expanduser("~")
def gitignore_pattern(path: str, folder: bool = False) -> str:
"""A .gitignore line matching exactly this path (from the repo root)."""
p = path.replace("\\", "/").strip("/")
p = re.sub(r"([*?\[])", r"\\\1", p) # glob characters match literally
if p.endswith(" "):
p = p[:-1] + "\\ " # a trailing space would be dropped
return "/" + p + ("/" if folder else "")
def parent_folders(path: str) -> List[str]:
"""'a/b/c/file.txt' -> ['a/b/c', 'a/b', 'a'] (deepest first, like GitHub Desktop)."""
parts = path.replace("\\", "/").split("/")[:-1]
return ["/".join(parts[:i]) for i in range(len(parts), 0, -1)]
def copy_to_system_clipboard(text: str) -> bool:
"""Put text on the OS clipboard. Returns False if there's no way to (caller falls back to OSC 52)."""
try:
if IS_WIN:
import ctypes
from ctypes import wintypes
user32 = ctypes.WinDLL("user32", use_last_error=True)
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
user32.OpenClipboard.argtypes = [wintypes.HWND]
user32.OpenClipboard.restype = wintypes.BOOL
user32.EmptyClipboard.restype = wintypes.BOOL
user32.SetClipboardData.argtypes = [wintypes.UINT, wintypes.HANDLE]
user32.SetClipboardData.restype = wintypes.HANDLE
user32.CloseClipboard.restype = wintypes.BOOL
kernel32.GlobalAlloc.argtypes = [wintypes.UINT, ctypes.c_size_t]
kernel32.GlobalAlloc.restype = wintypes.HGLOBAL
kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL]
kernel32.GlobalLock.restype = ctypes.c_void_p
kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL]
data = text.encode("utf-16-le") + b"\0\0"
handle = kernel32.GlobalAlloc(0x0002, len(data)) # GMEM_MOVEABLE
ptr = kernel32.GlobalLock(handle)
ctypes.memmove(ptr, data, len(data))
kernel32.GlobalUnlock(handle)
for _ in range(10): # another app may hold it briefly
if user32.OpenClipboard(None):
break
time.sleep(0.05)
else:
return False
try:
user32.EmptyClipboard()
return bool(user32.SetClipboardData(13, handle)) # CF_UNICODETEXT
finally:
user32.CloseClipboard()
if sys.platform == "darwin":
return subprocess.run(["pbcopy"], input=text.encode("utf-8")).returncode == 0
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("wl-copy"):
return subprocess.run(["wl-copy"], input=text.encode("utf-8")).returncode == 0
if os.environ.get("DISPLAY"):
for cmd in (["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]):
if shutil.which(cmd[0]):
return subprocess.run(cmd, input=text.encode("utf-8")).returncode == 0
except (OSError, AttributeError, ValueError):
pass
return False
def file_manager_name() -> str:
return "Explorer" if IS_WIN else ("Finder" if sys.platform == "darwin" else "file manager")
@@ -1155,22 +1223,64 @@ class Tick(Static):
style=f"bold {C_BLUE}" if self.checked else C_MUTED)
def on_click(self, event: events.Click) -> None:
if event.button != 1: # right-click: let the row open its menu
return
event.stop()
self.checked = not self.checked
self.post_message(self.Toggled(self))
def fit_path(path: str, width: int) -> str:
"""Shorten a path in the middle so the file name stays visible (like GitHub Desktop)."""
if width <= 0 or len(path) <= width:
return path
name = path.rsplit("/", 1)[-1]
if len(name) + 8 > width: # the name alone barely fits: cut the middle of it all
head = max(3, width // 3)
return path[:head] + "…" + path[-(width - head - 1):]
return path[:width - len(name) - 2] + "…/" + name
class PathLabel(Static):
"""A file path that fits its width, keeping the file name."""
def __init__(self, fc: FileChange, **kw) -> None:
super().__init__(**kw)
self.fc = fc
def render(self) -> Text:
width = self.size.width
shown = fit_path(self.fc.path, width - (len(self.fc.orig) + 3 if self.fc.orig and width > 60 else 0))
t = Text(no_wrap=True, overflow="ellipsis")
if self.fc.orig and width > 60:
t.append(self.fc.orig + " → ", style=C_DIM)
d, sep, name = shown.rpartition("/")
if sep:
t.append(d + "/", style=C_MUTED)
t.append(name)
return t
class FileRow(ListItem):
class MenuRequested(Message):
def __init__(self, row: "FileRow", x: int, y: int) -> None:
super().__init__()
self.row, self.x, self.y = row, x, y
def __init__(self, fc: FileChange, checked: bool) -> None:
super().__init__(classes="filerow")
self.fc = fc
self._checked = checked
def on_click(self, event: events.Click) -> None:
if event.button == 3: # right-click -> context menu (the row also gets selected)
self.post_message(self.MenuRequested(self, event.screen_x, event.screen_y))
def compose(self) -> ComposeResult:
icon, color, _ = KIND_ICON.get(self.fc.kind, ("●", C_YELLOW, ""))
with Horizontal():
yield Tick(self._checked, key=self.fc.path, classes="tick")
yield Label(path_text(self.fc), classes="fpath")
yield PathLabel(self.fc, classes="fpath")
yield Label(Text(icon, style=f"bold {color}"), classes="fkind")
@@ -1418,6 +1528,55 @@ class AddLocalDialog(ChooseFolderMixin, ModalScreen):
self.dismiss(None)
class ContextMenu(ModalScreen):
"""A small popup menu at a screen position. Returns (item id, (x, y) of that item) or None.
items: (id, label) or (id, label, enabled), or None for a separator line.
"""
BINDINGS = [Binding("escape", "cancel", "Close"), Binding("left", "cancel", "Back", show=False)]
def __init__(self, items, x: int, y: int) -> None:
super().__init__()
self.items, self.x, self.y = items, x, y
def compose(self) -> ComposeResult:
options = []
for item in self.items:
if item is None:
options.append(None)
else:
ident, label, *rest = item
options.append(Option(Text(label), id=ident, disabled=bool(rest) and not rest[0]))
yield OptionList(*options, id="ctx-menu")
def on_mount(self) -> None:
menu = self.query_one("#ctx-menu", OptionList)
menu.styles.offset = (self.x, self.y)
menu.focus()
self.call_after_refresh(self._keep_on_screen)
def _keep_on_screen(self) -> None:
menu = self.query_one("#ctx-menu", OptionList)
w, h = menu.outer_size
x = max(0, min(self.x, self.size.width - w))
y = max(0, min(self.y, self.size.height - h))
menu.styles.offset = (x, y)
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
event.stop()
menu = self.query_one("#ctx-menu", OptionList)
anchor = (menu.region.right, menu.region.y + 1 + event.option_index - menu.scroll_offset.y)
self.dismiss((event.option.id, anchor))
def on_click(self, event: events.Click) -> None:
menu = self.query_one("#ctx-menu", OptionList)
if not menu.region.contains(event.screen_x, event.screen_y):
self.dismiss(None) # clicked outside the menu
def action_cancel(self) -> None:
self.dismiss(None)
class SimpleRow(ListItem):
def __init__(self, value, *lines: Text) -> None:
super().__init__()
@@ -1992,6 +2151,10 @@ TabPane { padding: 0; height: 1fr; }
#gitlog { height: 12; border-top: solid #444d56; background: #1f2428; }
ModalScreen { align: center middle; background: rgba(0, 0, 0, 0.55); }
ContextMenu { align: left top; background: rgba(0, 0, 0, 0.15); }
#ctx-menu { width: auto; min-width: 30; max-width: 80; height: auto; max-height: 24;
background: #2f363d; border: round #586069; padding: 0; }
#ctx-menu > .option-list--option { padding: 0 1; }
.dialog { width: 76; max-width: 95%; height: auto; max-height: 90%;
background: #2f363d; border: round #586069; padding: 1 2; }
.dialog.wide { width: 90; }
@@ -2041,6 +2204,8 @@ class GitDesk(App):
Binding("l", "toggle_log", "Log"),
Binding("r", "refresh", "Refresh"),
Binding("space", "toggle_file", "Include file", show=False),
Binding("m", "file_menu", "File menu", show=False),
Binding("shift+f10", "file_menu", "File menu", show=False),
Binding("q", "quit", "Quit"),
]
@@ -2148,7 +2313,7 @@ class GitDesk(App):
return False
APP_KEYS = {"fetch", "pull", "push", "branches", "stash", "stashes", "open_repo", "discard", "view_remote",
"new_repo", "clone_repo", "add_remote", "repositories",
"new_repo", "clone_repo", "add_remote", "repositories", "file_menu",
"toggle_log", "refresh", "toggle_file", "quit"}
def check_action(self, action: str, parameters) -> Optional[bool]:
@@ -2466,7 +2631,8 @@ class GitDesk(App):
icon, color, label = KIND_ICON.get(fc.kind, ("●", C_YELLOW, ""))
title = Text(no_wrap=True, overflow="ellipsis")
title.append(f"{icon} ", style=f"bold {color}")
title.append(fc.path, style="bold")
room = self.query_one("#diff-header").size.width - len(label) - 48 # leave space for the buttons
title.append(fit_path(fc.path, max(20, room)), style="bold")
title.append(f" {label}", style=C_MUTED)
self.query_one("#diff-title", Label).update(title)
self.query_one("#open-file").display = True
@@ -2628,6 +2794,160 @@ class GitDesk(App):
self.update_counts()
self.update_commit_button()
@on(FileRow.MenuRequested)
def _row_menu(self, event: FileRow.MenuRequested) -> None:
if not self.busy:
self.run_worker(self.file_menu_flow(event.row.fc, event.x, event.y), exit_on_error=False)
def action_file_menu(self) -> None:
lv = self.query_one("#files", ListView)
row = lv.highlighted_child
if self.mode == "changes" and isinstance(row, FileRow) and not self.busy:
self.run_worker(self.file_menu_flow(row.fc, row.region.x + 6, row.region.y + 1), exit_on_error=False)
async def file_menu_flow(self, fc: FileChange, x: int, y: int) -> None:
full = os.path.normpath(os.path.join(self.git.path, fc.path))
exists = os.path.exists(full)
ext = os.path.splitext(fc.path)[1]
folders = parent_folders(fc.path)
editor = "Visual Studio Code" if shutil.which("code") else editor_name()
items = [
("discard", "Discard changes...", not fc.is_conflict),
None,
("ignore_file", "Ignore file (add to .gitignore)"),
("ignore_folder", "Ignore folder (add to .gitignore) >", bool(folders)),
("ignore_ext", f"Ignore all {ext} files (add to .gitignore)" if ext else "Ignore all files of this type",
bool(ext)),
None,
("copy_path", "Copy file path"),
("copy_rel", "Copy relative file path"),
None,
("reveal", f"Show in {file_manager_name()}"),
("open_editor", f"Open in {editor}", exists),
("open_default", "Open with default program", exists),
]
res = await self.push_screen_wait(ContextMenu(items, x, y))
if not res:
return
choice, (ax, ay) = res
if choice == "discard":
await self.discard_flow([fc])
elif choice == "ignore_file":
await self.ignore_flow(gitignore_pattern(fc.path))
elif choice == "ignore_folder":
sub = await self.push_screen_wait(ContextMenu([(f, "/" + f) for f in folders], ax, ay - 1))
if sub:
await self.ignore_flow(gitignore_pattern(sub[0], folder=True))
elif choice == "ignore_ext":
await self.ignore_flow("*" + re.sub(r"([*?\[])", r"\\\1", ext))
elif choice == "copy_path":
self.copy_text(full)
elif choice == "copy_rel":
self.copy_text(os.path.normpath(fc.path))
elif choice == "reveal":
self.reveal_in_file_manager(full)
elif choice == "open_editor":
self.action_open_editor(full)
elif choice == "open_default":
self.open_default(full)
def copy_text(self, text: str) -> None:
if not copy_to_system_clipboard(text):
self.copy_to_clipboard(text) # terminal clipboard (OSC 52) - works in Windows Terminal
self.notify(f"Copied: {text}")
def reveal_in_file_manager(self, path: str) -> None:
try:
if IS_WIN:
if os.path.exists(path):
subprocess.Popen(f'explorer /select,"{path}"')
else:
os.startfile(os.path.dirname(path)) # type: ignore[attr-defined]
elif sys.platform == "darwin":
subprocess.Popen(["open", "-R", path] if os.path.exists(path) else ["open", os.path.dirname(path)])
elif shutil.which("xdg-open") and (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
subprocess.Popen(["xdg-open", os.path.dirname(path)], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
else:
self.notify("No file manager available here.", severity="warning")
except OSError as e:
self.notify(f"Couldn't open the file manager: {e}", severity="error")
def open_default(self, path: str) -> None:
try:
if IS_WIN:
os.startfile(path) # type: ignore[attr-defined]
elif sys.platform == "darwin":
subprocess.Popen(["open", path])
elif shutil.which("xdg-open") and (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
subprocess.Popen(["xdg-open", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True)
else:
self.notify("No desktop here to open files with.", severity="warning")
return
self.notify(f"Opening {os.path.basename(path)}")
except OSError as e:
self.notify(f"Couldn't open it: {e}", severity="error")
async def ignore_flow(self, pattern: str) -> None:
"""Add a line to the repo's .gitignore; offer to stop tracking files it now covers."""
if not self.idle_or_warn():
return
g = self.git
ignore_file = os.path.join(g.path, ".gitignore")
try:
with open(ignore_file, encoding="utf-8", errors="replace") as fh:
current = fh.read()
except FileNotFoundError:
current = ""
except OSError as e:
await self.error_dialog("Couldn't read .gitignore", str(e))
return
if pattern in (line.strip() for line in current.splitlines()):
self.notify(f"{pattern} is already in .gitignore.")
return
def tracked_ignored() -> Set[str]:
r = g.run("ls-files", "-z", "--cached", "--ignored", "--exclude-standard", log=False)
return {f for f in r.out.split("\0") if f}
before = await asyncio.to_thread(tracked_ignored)
try:
with open(ignore_file, "a", encoding="utf-8", newline="\n") as fh:
if current and not current.endswith("\n"):
fh.write("\n")
fh.write(pattern + "\n")
except OSError as e:
await self.error_dialog("Couldn't update .gitignore", str(e))
return
self._log_cmd([f"(gitdesk added '{pattern}' to .gitignore)"], Result(0))
newly = sorted(await asyncio.to_thread(tracked_ignored) - before)
self.notify(f"Added {pattern} to .gitignore.")
if newly:
body = Text(f"{plural(len(newly), 'file')} matching {pattern} "
f"{'is' if len(newly) == 1 else 'are'} already tracked by git, so ignoring "
f"{'it' if len(newly) == 1 else 'them'} won't hide changes:\n\n")
for f in newly[:10]:
body.append(f" {f}\n", style="bold")
if len(newly) > 10:
body.append(f" … and {len(newly) - 10} more\n", style=C_MUTED)
body.append("\nStop tracking them? They stay on your disk; they're removed from the repository "
"in your next commit (anyone who pulls will see them deleted).", style=C_MUTED)
choice = await self.ask("Stop tracking these files too?", body,
[("untrack", "Stop tracking", "warning"), ("keep", "Keep tracking", "default")],
cancel="keep")
if choice == "untrack":
async def flow():
for i in range(0, len(newly), 100): # keep command lines short on Windows
r = await self.gitop("--literal-pathspecs", "rm", "-q", "--cached", "--", *newly[i:i + 100])
if not r.ok:
await self.fail("Couldn't stop tracking the files", r)
return
self.notify(f"Stopped tracking {plural(len(newly), 'file')}. Commit to finish.")
self.start_op("Updating", flow)
return
await self.refresh_state(force=True)
def action_toggle_file(self) -> None:
lv = self.query_one("#files", ListView)
if self.mode != "changes" or not lv.has_focus or not isinstance(lv.highlighted_child, FileRow):
@@ -3236,9 +3556,30 @@ class GitDesk(App):
if r.ok:
now = await self.load_state()
present = {f.path for f in now.files}
# A "deleted" file that's still on disk was un-tracked (git rm --cached);
# the reset above undid that, so redo it rather than adding the file back.
untrack = [f.path for f in files if f.kind == "D"
and os.path.exists(os.path.join(self.git.path, f.path))]
specs = []
for f in files:
if f.path in untrack:
continue
specs += [p for p in (f.path, f.orig) if p and p in present]
if untrack:
r = await self.gitop("--literal-pathspecs", "rm", "-q", "--cached", "--", *untrack)
if not r.ok:
await self.fail("Commit failed", r)
return
if not specs and untrack:
r = await self.gitop("commit", "--cleanup=whitespace", "-F", "-", input=message)
if not r.ok:
await self.fail("Commit failed", r)
return
self.query_one("#summary", Input).value = ""
self.query_one("#description", TextArea).text = ""
self.excluded.clear()
self.notify(f"Committed: {summary}")
return
if not specs:
self.notify("The ticked files have no changes left to commit.", severity="warning")
return
@@ -3638,7 +3979,23 @@ class GitDesk(App):
await self.run_action(action)
def selftest() -> int:
"""Start the UI headless for a moment and quit - proves every module made it into a build."""
import tempfile
async def autopilot(pilot) -> None:
await pilot.pause(0.5)
pilot.app.exit(0 if pilot.app.query("#tb-repo") else 1)
nowhere = os.path.join(tempfile.gettempdir(), "gitdesk-selftest-no-such-folder")
rc = GitDesk(nowhere).run(headless=True, size=(120, 40), auto_pilot=autopilot)
print("gitdesk selftest ok" if rc == 0 else f"gitdesk selftest failed ({rc})")
return 0 if rc == 0 else 1
def main() -> None:
if len(sys.argv) > 1 and sys.argv[1] == "--selftest":
sys.exit(selftest())
if shutil.which("git") is None:
print("gitdesk: git isn't installed or isn't in your PATH.")
sys.exit(1)