diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e03e15 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.build/ diff --git a/README.md b/README.md index c305574..cb6dfa3 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,21 @@ A GitHub Desktop-style git client that runs in your terminal. Use the mouse or t This makes a single file that runs without Python or Textual installed. -- **Windows:** double-click `build.bat`. You get `dist\gitdesk.exe`. +- **Windows:** double-click `build.bat`. In PowerShell type `.\build.bat`; PowerShell won't + run it without the `.\`. You get `dist\gitdesk.exe`. - **Linux / macOS:** run `./build.sh`. You get `dist/gitdesk`. +`build.py` does the work, and the .bat/.sh just start it. It: +- sets up its own environment in `.build`; +- installs PyInstaller and Textual; +- builds the exe; +- test-starts the new exe to make sure it works; +- logs everything to `build.log`. + +If something goes wrong it says why in plain English (old exe still open, antivirus, +OneDrive, no internet...). Send `build.log` over if the reason isn't clear. + Build on the system you want to run it on: a Windows exe has to be built on Windows. -The scripts use their own `.build-venv` folder and don't touch your Python install. The exe takes a second or two to start because it unpacks itself first. ## Run @@ -61,6 +71,16 @@ Tip: put this folder on your PATH (Windows), or symlink it on Linux/macOS Discard keeps a backup in Stashed changes, so it can be undone. More → Discard ticked changes does several files at once. Below the list are Stash all / Stashed, and the commit summary + description. +- **Right-click a changed file** (or press `m` / Shift+F10) for the same menu as GitHub Desktop: + - **Discard changes…** + - **Ignore file**, **Ignore folder ▸** (pick which parent folder), or **Ignore all .ext files**. + Each one adds a line to the repo's `.gitignore`. + - **Copy file path / relative path** + - **Show in Explorer**, **Open in VS Code**, **Open with default program** + + Ignoring a file git already tracks doesn't hide its changes, so if the new rule covers tracked + files, gitdesk offers **Stop tracking**. The files stay on your disk and leave the repository + in your next commit. - **History tab:** recent commits (↑ = not pushed yet). Click one to see what changed, or **View on GitHub** to open that commit's page. - **View on GitHub** (`g`, More menu, or the card on the "No local changes" screen) opens @@ -94,4 +114,4 @@ Tip: put this folder on your PATH (Windows), or symlink it on Linux/macOS behind by a crashed git, it offers **Remove lock file & retry**. Keys: `f` fetch · `p` pull · `u` push · `b` branch · `s` stash · `t` stashes · `d` discard · -`o` add local repo · `n` new repo · `c` clone · `g` view on web · `l` log · `r` refresh · `space` tick/untick file · `q` quit +`o` add local repo · `n` new repo · `c` clone · `g` view on web · `l` log · `r` refresh · `space` tick/untick file · `m` file menu · `q` quit diff --git a/build.bat b/build.bat index 5824582..901e947 100644 --- a/build.bat +++ b/build.bat @@ -1,48 +1,20 @@ @echo off -rem ========================================================================== -rem build.bat - builds gitdesk into a single Windows executable: dist\gitdesk.exe -rem -rem Needs Python 3.9+ installed. Double-click it or run it from this folder. -rem It makes its own throwaway environment in .build-venv, so it doesn't touch -rem your normal Python packages. -rem ========================================================================== +rem build.bat - builds dist\gitdesk.exe. Double-click it, or run it from a terminal. +rem In PowerShell type .\build.bat (PowerShell won't run it without the .\ in front). +rem Needs Python 3.9+. The real work is done by build.py; everything is logged to build.log. setlocal cd /d "%~dp0" - set "PY=" where py >nul 2>&1 && set "PY=py -3" if not defined PY where python >nul 2>&1 && set "PY=python" if not defined PY ( echo Python 3 was not found. Install it from https://www.python.org/downloads/ - goto :fail + echo and tick "Add python.exe to PATH" during setup. + pause + exit /b 1 ) - -echo [1/3] Creating the build environment - .build-venv ... -if not exist ".build-venv\Scripts\python.exe" %PY% -m venv .build-venv -if not exist ".build-venv\Scripts\python.exe" goto :fail -set "VPY=.build-venv\Scripts\python.exe" - -echo [2/3] Installing PyInstaller and Textual... -"%VPY%" -m pip install --quiet --upgrade pip -"%VPY%" -m pip install --quiet pyinstaller -r requirements.txt -if errorlevel 1 goto :fail - -echo [3/3] Building gitdesk.exe... -rem Textual and Rich load some of their modules on demand, so bundle all of them. -"%VPY%" -m PyInstaller --noconfirm --clean --onefile --name gitdesk ^ - --collect-submodules textual --collect-data textual --collect-submodules rich ^ - --specpath build --workpath build --distpath dist ^ - gitdesk.py -if errorlevel 1 goto :fail - +%PY% "%~dp0build.py" %* +set "RC=%errorlevel%" echo. -echo Done: %CD%\dist\gitdesk.exe -echo Copy it anywhere - a folder on your PATH is handy - and run "gitdesk" inside a repo. pause -exit /b 0 - -:fail -echo. -echo Build failed - see the messages above. -pause -exit /b 1 +exit /b %RC% diff --git a/build.log b/build.log new file mode 100644 index 0000000..6266196 --- /dev/null +++ b/build.log @@ -0,0 +1,221 @@ +gitdesk build - Python 3.15.2 (C:\Users\Lenrui\AppData\Local\Python\pythoncore-3.15-64\python.exe) on win32 +Folder: D:\Gitea\GitDesk + +[1/4] Build environment +> C:\Users\Lenrui\AppData\Local\Python\pythoncore-3.15-64\python.exe -m venv D:\Gitea\GitDesk\.build\venv +Using D:\Gitea\GitDesk\.build\venv + +[2/4] Installing PyInstaller and Textual (downloads them the first time) +> D:\Gitea\GitDesk\.build\venv\Scripts\python.exe -m pip install --disable-pip-version-check --upgrade pip +Requirement already satisfied: pip in .\.build\venv\Lib\site-packages (26.2.2) +Collecting pip + Downloading pip-26.3-py3-none-any.whl.metadata (4.6 kB) +Downloading pip-26.3-py3-none-any.whl (2.0 MB) + ---------------------------------------- 2.0/2.0 MB 32.4 MB/s 0:00:00 +Installing collected packages: pip + Attempting uninstall: pip + Found existing installation: pip 26.2.2 + Uninstalling pip-26.2.2: + Successfully uninstalled pip-26.2.2 +Successfully installed pip-26.3 +> D:\Gitea\GitDesk\.build\venv\Scripts\python.exe -m pip install --disable-pip-version-check --upgrade pyinstaller -r D:\Gitea\GitDesk\requirements.txt +Collecting pyinstaller + Downloading pyinstaller-6.22.3-py3-none-win_amd64.whl.metadata (8.5 kB) +Collecting textual>=8.2 (from -r D:\Gitea\GitDesk\requirements.txt (line 1)) + Using cached textual-8.2.8-py3-none-any.whl.metadata (9.1 kB) +Collecting altgraph (from pyinstaller) + Downloading altgraph-0.17.5-py2.py3-none-any.whl.metadata (7.5 kB) +Collecting packaging>=22.0 (from pyinstaller) + Downloading packaging-26.3-py3-none-any.whl.metadata (3.5 kB) +Collecting pefile>=2022.5.30 (from pyinstaller) + Downloading pefile-2024.8.26-py3-none-any.whl.metadata (1.4 kB) +Collecting pyinstaller-hooks-contrib>=2026.7 (from pyinstaller) + Downloading pyinstaller_hooks_contrib-2026.9-py3-none-any.whl.metadata (16 kB) +Collecting pywin32-ctypes>=0.2.1 (from pyinstaller) + Downloading pywin32_ctypes-0.2.3-py3-none-any.whl.metadata (3.9 kB) +Collecting setuptools>=42.0.0 (from pyinstaller) + Downloading setuptools-84.2.1-py3-none-any.whl.metadata (6.6 kB) +Collecting markdown-it-py>=2.1.0 (from markdown-it-py[linkify]>=2.1.0->textual>=8.2->-r D:\Gitea\GitDesk\requirements.txt (line 1)) + Using cached markdown_it_py-4.2.0-py3-none-any.whl.metadata (7.4 kB) +Collecting mdit-py-plugins (from textual>=8.2->-r D:\Gitea\GitDesk\requirements.txt (line 1)) + Using cached mdit_py_plugins-0.6.1-py3-none-any.whl.metadata (2.9 kB) +Collecting platformdirs<5,>=3.6.0 (from textual>=8.2->-r D:\Gitea\GitDesk\requirements.txt (line 1)) + Using cached platformdirs-4.14.5-py3-none-any.whl.metadata (5.7 kB) +Collecting pygments<3.0.0,>=2.19.2 (from textual>=8.2->-r D:\Gitea\GitDesk\requirements.txt (line 1)) + Using cached pygments-2.21.0-py3-none-any.whl.metadata (2.5 kB) +Collecting rich>=14.2.0 (from textual>=8.2->-r D:\Gitea\GitDesk\requirements.txt (line 1)) + Using cached rich-15.0.0-py3-none-any.whl.metadata (18 kB) +Collecting typing-extensions<5.0.0,>=4.4.0 (from textual>=8.2->-r D:\Gitea\GitDesk\requirements.txt (line 1)) + Using cached typing_extensions-4.16.0-py3-none-any.whl.metadata (3.3 kB) +Collecting mdurl~=0.1 (from markdown-it-py>=2.1.0->markdown-it-py[linkify]>=2.1.0->textual>=8.2->-r D:\Gitea\GitDesk\requirements.txt (line 1)) + Using cached mdurl-0.1.2-py3-none-any.whl.metadata (1.6 kB) +Collecting linkify-it-py<3,>=1 (from markdown-it-py[linkify]>=2.1.0->textual>=8.2->-r D:\Gitea\GitDesk\requirements.txt (line 1)) + Using cached linkify_it_py-2.2.0-py3-none-any.whl.metadata (8.6 kB) +Downloading pyinstaller-6.22.3-py3-none-win_amd64.whl (1.5 MB) + ---------------------------------------- 1.5/1.5 MB 31.6 MB/s 0:00:00 +Using cached textual-8.2.8-py3-none-any.whl (731 kB) +Using cached platformdirs-4.14.5-py3-none-any.whl (35 kB) +Using cached pygments-2.21.0-py3-none-any.whl (1.3 MB) +Using cached typing_extensions-4.16.0-py3-none-any.whl (45 kB) +Using cached markdown_it_py-4.2.0-py3-none-any.whl (91 kB) +Using cached mdurl-0.1.2-py3-none-any.whl (10.0 kB) +Using cached linkify_it_py-2.2.0-py3-none-any.whl (21 kB) +Downloading packaging-26.3-py3-none-any.whl (129 kB) +Downloading pefile-2024.8.26-py3-none-any.whl (74 kB) +Downloading pyinstaller_hooks_contrib-2026.9-py3-none-any.whl (462 kB) +Downloading pywin32_ctypes-0.2.3-py3-none-any.whl (30 kB) +Using cached rich-15.0.0-py3-none-any.whl (310 kB) +Downloading setuptools-84.2.1-py3-none-any.whl (876 kB) + ---------------------------------------- 876.3/876.3 kB 50.8 MB/s 0:00:00 +Downloading altgraph-0.17.5-py2.py3-none-any.whl (21 kB) +Using cached mdit_py_plugins-0.6.1-py3-none-any.whl (66 kB) +Installing collected packages: altgraph, typing-extensions, setuptools, pywin32-ctypes, pygments, platformdirs, pefile, packaging, mdurl, linkify-it-py, pyinstaller-hooks-contrib, markdown-it-py, rich, pyinstaller, mdit-py-plugins, textual + +Successfully installed altgraph-0.17.5 linkify-it-py-2.2.0 markdown-it-py-4.2.0 mdit-py-plugins-0.6.1 mdurl-0.1.2 packaging-26.3 pefile-2024.8.26 platformdirs-4.14.5 pygments-2.21.0 pyinstaller-6.22.3 pyinstaller-hooks-contrib-2026.9 pywin32-ctypes-0.2.3 rich-15.0.0 setuptools-84.2.1 textual-8.2.8 typing-extensions-4.16.0 + +[3/4] Building the executable (takes a minute) +> D:\Gitea\GitDesk\.build\venv\Scripts\python.exe -m PyInstaller --noconfirm --clean --onefile --name gitdesk --collect-submodules textual --collect-data textual --collect-submodules rich --specpath D:\Gitea\GitDesk\.build\work --workpath D:\Gitea\GitDesk\.build\work --distpath D:\Gitea\GitDesk\dist D:\Gitea\GitDesk\gitdesk.py +225 INFO: PyInstaller: 6.22.3, contrib hooks: 2026.9 +225 INFO: Python: 3.15.2 +253 INFO: Platform: Windows-11-10.0.26200-SP0 +253 INFO: Python environment: D:\Gitea\GitDesk\.build\venv +254 INFO: wrote D:\Gitea\GitDesk\.build\work\gitdesk.spec +256 INFO: Removing temporary files and cleaning cache in C:\Users\Lenrui\AppData\Local\pyinstaller +885 INFO: Module search paths (PYTHONPATH): +['D:\\Gitea\\GitDesk', + 'C:\\Users\\Lenrui\\AppData\\Local\\Python\\pythoncore-3.15-64\\python315.zip', + 'C:\\Users\\Lenrui\\AppData\\Local\\Python\\pythoncore-3.15-64\\DLLs', + 'C:\\Users\\Lenrui\\AppData\\Local\\Python\\pythoncore-3.15-64\\Lib', + 'C:\\Users\\Lenrui\\AppData\\Local\\Python\\pythoncore-3.15-64', + 'D:\\Gitea\\GitDesk\\.build\\venv', + 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages', + 'D:\\Gitea\\GitDesk'] +1149 INFO: Appending 'datas' from .spec +1150 INFO: checking Analysis +1150 INFO: Building Analysis because Analysis-00.toc is non existent +1150 INFO: Looking for Python shared library... +1150 INFO: Using Python shared library: C:\Users\Lenrui\AppData\Local\Python\pythoncore-3.15-64\python315.dll +1150 INFO: Running Analysis Analysis-00.toc +1150 INFO: Target bytecode optimization level: 0 +1150 INFO: Initializing module dependency graph... +1158 INFO: Initializing module graph hook caches... +1173 INFO: Analyzing modules for base_library.zip ... +2171 INFO: Processing standard module hook 'hook-math.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks' +2272 INFO: Processing standard module hook 'hook-encodings.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks' +4442 INFO: Processing standard module hook 'hook-difflib.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks' +4457 INFO: Processing standard module hook 'hook-heapq.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks' +4653 INFO: Processing standard module hook 'hook-pickle.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks' +6096 INFO: Caching module dependency graph... +6110 INFO: Analyzing D:\Gitea\GitDesk\gitdesk.py +6438 INFO: Processing standard module hook 'hook-multiprocessing.util.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks' +6529 INFO: Processing standard module hook 'hook-xml.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks' +6831 INFO: Processing standard module hook 'hook-sysconfig.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks' +6841 INFO: Processing standard module hook 'hook-_ctypes.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks' +7327 INFO: Processing pre-find-module-path hook 'hook-tkinter.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks\\pre_find_module_path' +7329 INFO: TclTkInfo: initializing cached Tcl/Tk info... +7549 INFO: Processing standard module hook 'hook-_tkinter.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks' +7582 INFO: Processing standard module hook 'hook-rich.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks' +8102 INFO: Processing standard module hook 'hook-pygments.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks' +9397 INFO: Processing pre-safe-import-module hook 'hook-typing_extensions.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module' +9399 INFO: SetuptoolsInfo: initializing cached setuptools info... +10121 INFO: Processing standard module hook 'hook-platform.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks' +10240 INFO: Processing standard module hook 'hook-webbrowser.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks' +10489 INFO: Processing pre-safe-import-module hook 'hook-platformdirs.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks\\pre_safe_import_module' +10500 INFO: Processing standard module hook 'hook-platformdirs.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\stdhooks' +11887 INFO: Analyzing hidden import 'textual.__main__' +11930 INFO: Analyzing hidden import 'textual._binary_encode' +11935 INFO: Analyzing hidden import 'textual._doc' +11942 INFO: Analyzing hidden import 'textual._layout_resolve' +11944 INFO: Analyzing hidden import 'textual._sleep' +11946 INFO: Analyzing hidden import 'textual.drivers._byte_stream' +11949 INFO: Analyzing hidden import 'textual.drivers._input_reader' +11952 INFO: Analyzing hidden import 'textual.drivers.web_driver' +11959 INFO: Analyzing hidden import 'textual.logging' +11961 INFO: Analyzing hidden import 'textual.pad' +11963 INFO: Analyzing hidden import 'textual.renderables.gradient' +11966 INFO: Analyzing hidden import 'textual.widgets._markdown_viewer' +11967 INFO: Analyzing hidden import 'textual.widgets._tab' +11967 INFO: Analyzing hidden import 'textual.widgets._tab_pane' +11967 INFO: Analyzing hidden import 'textual.widgets.button' +11968 INFO: Analyzing hidden import 'textual.widgets.collapsible' +11968 INFO: Analyzing hidden import 'textual.widgets.data_table' +11969 INFO: Analyzing hidden import 'textual.widgets.directory_tree' +11969 INFO: Analyzing hidden import 'textual.widgets.input' +11969 INFO: Analyzing hidden import 'textual.widgets.markdown' +11969 INFO: Analyzing hidden import 'textual.widgets.rule' +11970 INFO: Analyzing hidden import 'textual.widgets.select' +11971 INFO: Analyzing hidden import 'textual.widgets.selection_list' +11971 INFO: Analyzing hidden import 'textual.widgets.tabbed_content' +11972 INFO: Analyzing hidden import 'textual.widgets.text_area' +11972 INFO: Analyzing hidden import 'textual.widgets.tree' +11973 INFO: Analyzing hidden import 'rich._unicode_data.unicode10-0-0' +11980 INFO: Analyzing hidden import 'rich._unicode_data.unicode11-0-0' +11988 INFO: Analyzing hidden import 'rich._unicode_data.unicode12-0-0' +11996 INFO: Analyzing hidden import 'rich._unicode_data.unicode12-1-0' +12004 INFO: Analyzing hidden import 'rich._unicode_data.unicode13-0-0' +12012 INFO: Analyzing hidden import 'rich._unicode_data.unicode14-0-0' +12021 INFO: Analyzing hidden import 'rich._unicode_data.unicode15-0-0' +12028 INFO: Analyzing hidden import 'rich._unicode_data.unicode15-1-0' +12036 INFO: Analyzing hidden import 'rich._unicode_data.unicode16-0-0' +12044 INFO: Analyzing hidden import 'rich._unicode_data.unicode17-0-0' +12052 INFO: Analyzing hidden import 'rich._unicode_data.unicode4-1-0' +12057 INFO: Analyzing hidden import 'rich._unicode_data.unicode5-0-0' +12061 INFO: Analyzing hidden import 'rich._unicode_data.unicode5-1-0' +12066 INFO: Analyzing hidden import 'rich._unicode_data.unicode5-2-0' +12071 INFO: Analyzing hidden import 'rich._unicode_data.unicode6-0-0' +12076 INFO: Analyzing hidden import 'rich._unicode_data.unicode6-1-0' +12080 INFO: Analyzing hidden import 'rich._unicode_data.unicode6-2-0' +12085 INFO: Analyzing hidden import 'rich._unicode_data.unicode6-3-0' +12090 INFO: Analyzing hidden import 'rich._unicode_data.unicode7-0-0' +12095 INFO: Analyzing hidden import 'rich._unicode_data.unicode8-0-0' +12101 INFO: Analyzing hidden import 'rich._unicode_data.unicode9-0-0' +12108 INFO: Analyzing hidden import 'rich.bar' +12110 INFO: Analyzing hidden import 'rich.diagnose' +12112 INFO: Analyzing hidden import 'rich.filesize' +12113 INFO: Analyzing hidden import 'rich.layout' +12124 INFO: Analyzing hidden import 'rich.logging' +12129 INFO: Analyzing hidden import 'rich.progress' +12164 INFO: Analyzing hidden import 'rich.prompt' +12172 INFO: Processing module hooks (post-graph stage)... +13638 INFO: Processing standard module hook 'hook-_tkinter.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks' +13643 INFO: Performing binary vs. data reclassification (18 entries) +13715 INFO: Looking for ctypes DLLs +13777 INFO: Analyzing run-time hooks ... +13780 INFO: Including run-time hook 'pyi_rth_inspect.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks' +13782 INFO: Including run-time hook 'pyi_rth_pkgutil.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks' +13783 INFO: Including run-time hook 'pyi_rth_multiprocessing.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks' +13785 INFO: Including run-time hook 'pyi_rth__tkinter.py' from 'D:\\Gitea\\GitDesk\\.build\\venv\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks' +13794 INFO: Creating base_library.zip... +13807 INFO: Looking for dynamic libraries +14152 INFO: Extra DLL search directories (AddDllDirectory): [] +14152 INFO: Extra DLL search directories (PATH): [] +14367 INFO: Warnings written to D:\Gitea\GitDesk\.build\work\gitdesk\warn-gitdesk.txt +14408 INFO: Graph cross-reference written to D:\Gitea\GitDesk\.build\work\gitdesk\xref-gitdesk.html +14429 INFO: checking PYZ +14429 INFO: Building PYZ because PYZ-00.toc is non existent +14429 INFO: Building PYZ (ZlibArchive) D:\Gitea\GitDesk\.build\work\gitdesk\PYZ-00.pyz +14802 INFO: Building PYZ (ZlibArchive) D:\Gitea\GitDesk\.build\work\gitdesk\PYZ-00.pyz completed successfully. +14841 INFO: checking PKG +14841 INFO: Building PKG because PKG-00.toc is non existent +14841 INFO: Building PKG (CArchive) gitdesk.pkg +15840 INFO: Building PKG (CArchive) gitdesk.pkg completed successfully. +15842 INFO: Bootloader D:\Gitea\GitDesk\.build\venv\Lib\site-packages\PyInstaller\bootloader\Windows-64bit-intel\run.exe +15842 INFO: checking EXE +15842 INFO: Building EXE because EXE-00.toc is non existent +15842 INFO: Building EXE from EXE-00.toc +15842 INFO: Copying bootloader EXE to D:\Gitea\GitDesk\dist\gitdesk.exe +15895 INFO: Copying icon to EXE +15923 INFO: Copying 0 resources to EXE +15923 INFO: Embedding manifest in EXE +15948 INFO: Appending PKG archive to EXE +16042 INFO: Fixing EXE headers +16280 INFO: Building EXE from EXE-00.toc completed successfully. +16282 INFO: Build complete! The results are available in: D:\Gitea\GitDesk\dist + +[4/4] Checking that the new executable starts +gitdesk selftest ok + +====================================================================== +Done: D:\Gitea\GitDesk\dist\gitdesk.exe (18 MB) +Copy it anywhere (a folder on your PATH is handy) and run 'gitdesk' inside a repo, +or just double-click it. diff --git a/build.py b/build.py new file mode 100644 index 0000000..163c5d0 --- /dev/null +++ b/build.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +""" +build.py - turns gitdesk.py into a single executable with PyInstaller. + + Windows: double-click build.bat (or in a terminal: py -3 build.py) + Linux / macOS: ./build.sh (or: python3 build.py) + +Result: dist/gitdesk.exe on Windows, dist/gitdesk elsewhere. Build it on the system +you'll run it on - PyInstaller can't make a Windows exe on Linux or the other way round. + +Everything is logged to build.log next to this file. The throwaway build +environment goes in a .build folder here - or, if this folder is inside OneDrive +(whose syncing locks files mid-build), in %LOCALAPPDATA%\\gitdesk-build instead. +""" +import os +import shutil +import subprocess +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +IS_WIN = os.name == "nt" +if IS_WIN and "onedrive" in HERE.lower() and os.environ.get("LOCALAPPDATA"): + CACHE = os.path.join(os.environ["LOCALAPPDATA"], "gitdesk-build") +else: + CACHE = os.path.join(HERE, ".build") +VENV = os.path.join(CACHE, "venv") +VPY = os.path.join(VENV, "Scripts", "python.exe") if IS_WIN else os.path.join(VENV, "bin", "python") +WORK = os.path.join(CACHE, "work") +DIST = os.path.join(HERE, "dist") +EXE = os.path.join(DIST, "gitdesk.exe" if IS_WIN else "gitdesk") +LOG = os.path.join(HERE, "build.log") + +_log = None + + +def say(msg: str = "") -> None: + try: + print(msg, flush=True) + except UnicodeEncodeError: # a console code page that can't show some character + print(msg.encode("ascii", "replace").decode(), flush=True) + if _log: + _log.write(msg + "\n") + _log.flush() + + +def run(cmd, show=True): + """Run a command, echo and log its output. Returns (exit code, last lines).""" + say("> " + " ".join(f'"{c}"' if " " in c else c for c in cmd)) + env = dict(os.environ, PYTHONUTF8="1", PYTHONIOENCODING="utf-8") # readable logs on any code page + try: + p = subprocess.Popen(cmd, cwd=HERE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, + stdin=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace") + except OSError as e: + say(f" couldn't start it: {e}") + return 1, [str(e)] + tail = [] + for line in p.stdout: + _log.write(line) + tail = (tail + [line.rstrip()])[-40:] + if show: + print(line, end="", flush=True) + p.wait() + _log.flush() + return p.returncode, tail + + +def fail(problem: str, hints=()) -> int: + say() + say("=" * 70) + say("BUILD FAILED: " + problem) + for h in hints: + say(" * " + h) + say() + say(f"Everything that happened is in {LOG}") + say("If the reason isn't clear, send that file over.") + return 1 + + +def common_hints(text: str): + hints = [] + low = text.lower() + if "permission" in low or "access is denied" in low or "winerror 5" in low or "winerror 32" in low: + hints.append("A file was locked. Close gitdesk if it's running, and close any window showing the " + "dist folder. Antivirus or OneDrive syncing can also lock files - try again in a minute, " + "or move this folder somewhere that isn't synced (e.g. C:\\dev\\gitdesk).") + if "virus" in low or "winerror 225" in low or "potentially unwanted" in low: + hints.append("Your antivirus blocked the new exe. Windows Defender sometimes flags PyInstaller " + "builds by mistake: allow it in Windows Security > Protection history, or add the " + "dist folder to the exclusions, then build again.") + if "could not find a version" in low or "connection" in low or "ssl" in low or "proxy" in low \ + or "getaddrinfo" in low or "network" in low: + hints.append("pip couldn't download packages - check the internet connection / VPN / proxy.") + if "filename too long" in low or "path too long" in low or "winerror 206" in low: + hints.append("A path got too long for Windows. Move this folder somewhere shorter, e.g. C:\\dev\\gitdesk.") + if "no module named venv" in low or "ensurepip" in low: + hints.append("This Python can't make virtual environments. On Debian/Ubuntu: sudo apt install python3-venv. " + "On Windows, reinstall Python from python.org.") + return hints + + +def free_old_exe() -> bool: + """Make sure the old exe can be replaced (it can't be overwritten while it's running).""" + if not os.path.exists(EXE): + return True + try: + os.remove(EXE) + return True + except OSError: + pass + # Windows lets you rename a running exe even though you can't delete it. + old = EXE + f".old-{int(time.time())}" + try: + os.replace(EXE, old) + say(f"The old exe is still running, so it was renamed to {os.path.basename(old)} - delete it later.") + return True + except OSError: + return False + + +def venv_works() -> bool: + if not os.path.exists(VPY): + return False + try: + return subprocess.run([VPY, "-c", "import sys, pip"], capture_output=True, timeout=60).returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + + +def main() -> int: + global _log + try: + _log = open(LOG, "w", encoding="utf-8") + except OSError as e: + print(f"Can't write {LOG}: {e}") + return 1 + say(f"gitdesk build - Python {sys.version.split()[0]} ({sys.executable}) on {sys.platform}") + say(f"Folder: {HERE}") + + if sys.version_info < (3, 9): + return fail("Python 3.9 or newer is needed.", ["Install a current Python from https://www.python.org/downloads/"]) + for name in ("gitdesk.py", "requirements.txt"): + if not os.path.exists(os.path.join(HERE, name)): + return fail(f"{name} isn't next to build.py.", + ["Put gitdesk.py, requirements.txt, build.py and build.bat in the same folder. If they " + "came in a .zip, extract it first - building from inside a zip doesn't work."]) + + say("\n[1/4] Build environment") + if not venv_works(): + if os.path.exists(VENV): + say("The build environment is broken or from an old Python - recreating it.") + shutil.rmtree(VENV, ignore_errors=True) + os.makedirs(CACHE, exist_ok=True) + rc, tail = run([sys.executable, "-m", "venv", VENV]) + if rc or not venv_works(): + return fail("Couldn't create the build environment.", common_hints("\n".join(tail)) or + ["Delete the folder " + CACHE + " and try again."]) + say(f"Using {VENV}") + + say("\n[2/4] Installing PyInstaller and Textual (downloads them the first time)") + run([VPY, "-m", "pip", "install", "--disable-pip-version-check", "--upgrade", "pip"], show=False) + rc, tail = run([VPY, "-m", "pip", "install", "--disable-pip-version-check", "--upgrade", + "pyinstaller", "-r", os.path.join(HERE, "requirements.txt")]) + if rc: + return fail("pip couldn't install PyInstaller / Textual.", common_hints("\n".join(tail)) or + ["Check your internet connection, then try again.", + "If it keeps failing, delete " + CACHE + " and try again."]) + if IS_WIN and subprocess.run([VPY, "-c", "import tkinter"], capture_output=True).returncode != 0: + say("Note: this Python has no tkinter, so Choose... in the exe will use the older Windows folder " + "dialog. To get the Explorer one: run the python.org installer again > Modify > tick " + "'tcl/tk and IDLE'.") + + say("\n[3/4] Building the executable (takes a minute)") + if not free_old_exe(): + return fail(f"{EXE} is in use and can't be replaced.", + ["Close gitdesk (every window running it), then build again."]) + cmd = [VPY, "-m", "PyInstaller", "--noconfirm", "--clean", "--onefile", "--name", "gitdesk", + # Textual and Rich load some of their modules on demand, so bundle all of them. + "--collect-submodules", "textual", "--collect-data", "textual", "--collect-submodules", "rich", + "--specpath", WORK, "--workpath", WORK, "--distpath", DIST, + os.path.join(HERE, "gitdesk.py")] + icon = os.path.join(HERE, "gitdesk.ico") + if IS_WIN and os.path.exists(icon): + cmd[-1:-1] = ["--icon", icon] + rc, tail = run(cmd) + if rc or not os.path.exists(EXE): + return fail("PyInstaller couldn't build the executable.", common_hints("\n".join(tail))) + + say("\n[4/4] Checking that the new executable starts") + try: + p = subprocess.run([EXE, "--selftest"], capture_output=True, text=True, timeout=180, + stdin=subprocess.DEVNULL, encoding="utf-8", errors="replace") + out = (p.stdout + p.stderr).strip() + ok = p.returncode == 0 and "selftest ok" in out + except (OSError, subprocess.SubprocessError) as e: + out, ok = str(e), False + _log.write(out + "\n") + if not ok: + say(out[-2000:]) + return fail("The executable was built but didn't start properly.", common_hints(out)) + + size = os.path.getsize(EXE) / 1024 / 1024 + say() + say("=" * 70) + say(f"Done: {EXE} ({size:.0f} MB)") + say("Copy it anywhere (a folder on your PATH is handy) and run 'gitdesk' inside a repo,") + say("or just double-click it.") + return 0 + + +if __name__ == "__main__": + try: + code = main() + except KeyboardInterrupt: + code = fail("Stopped.") + except Exception as e: # never just vanish - always explain + import traceback + say(traceback.format_exc()) + code = fail(f"Unexpected error: {e}") + sys.exit(code) diff --git a/build.sh b/build.sh index e555e47..38ce197 100644 --- a/build.sh +++ b/build.sh @@ -1,36 +1,7 @@ #!/usr/bin/env bash -# -# build.sh - builds gitdesk into a single executable: dist/gitdesk -# -# Run it on the system you want the executable for (Linux builds a Linux -# binary, macOS builds a macOS one). For Windows use build.bat on Windows. -# It makes its own throwaway environment in .build-venv, so it doesn't touch -# your system Python packages. -set -euo pipefail -cd "$(dirname "$0")" - +# build.sh - builds dist/gitdesk with PyInstaller. The real work is done by build.py; +# everything is logged to build.log. +cd "$(dirname "$0")" || exit 1 PY=$(command -v python3 || command -v python || true) [[ -n $PY ]] || { echo "Python 3 wasn't found. Install it first."; exit 1; } - -echo "[1/3] Creating the build environment (.build-venv)..." -if [[ ! -x .build-venv/bin/python ]]; then - "$PY" -m venv .build-venv || { - echo "Couldn't create a virtualenv. On Debian/Ubuntu: sudo apt install python3-venv"; exit 1; } -fi -VPY=.build-venv/bin/python - -echo "[2/3] Installing PyInstaller and Textual..." -"$VPY" -m pip install --quiet --upgrade pip -"$VPY" -m pip install --quiet pyinstaller -r requirements.txt - -echo "[3/3] Building..." -# Textual and Rich load some of their modules on demand, so tell PyInstaller to -# bundle all of them. -"$VPY" -m PyInstaller --noconfirm --clean --onefile --name gitdesk \ - --collect-submodules textual --collect-data textual --collect-submodules rich \ - --specpath build --workpath build --distpath dist \ - gitdesk.py - -echo -echo "Done: $PWD/dist/gitdesk" -echo "Copy it somewhere on your PATH (e.g. ~/.local/bin) and run 'gitdesk' inside a repo." +exec "$PY" build.py "$@" diff --git a/dist/gitdesk.exe b/dist/gitdesk.exe new file mode 100644 index 0000000..48064e7 Binary files /dev/null and b/dist/gitdesk.exe differ diff --git a/gitdesk.py b/gitdesk.py index 73b05a7..6ac804a 100644 --- a/gitdesk.py +++ b/gitdesk.py @@ -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)