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
+840 -78

No files matched your search

+1
View File
@@ -0,0 +1 @@
/.build/
+23 -3
View File
@@ -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. 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`. - **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. 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. The exe takes a second or two to start because it unpacks itself first.
## Run ## 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 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 ticked changes does several files at once. Below the list are Stash all / Stashed, and
the commit summary + description. 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, - **History tab:** recent commits (↑ = not pushed yet). Click one to see what changed,
or **View on GitHub** to open that commit's page. 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 - **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**. 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 · 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
+10 -38
View File
@@ -1,48 +1,20 @@
@echo off @echo off
rem ========================================================================== rem build.bat - builds dist\gitdesk.exe. Double-click it, or run it from a terminal.
rem build.bat - builds gitdesk into a single Windows executable: dist\gitdesk.exe rem In PowerShell type .\build.bat (PowerShell won't run it without the .\ in front).
rem rem Needs Python 3.9+. The real work is done by build.py; everything is logged to build.log.
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 ==========================================================================
setlocal setlocal
cd /d "%~dp0" cd /d "%~dp0"
set "PY=" set "PY="
where py >nul 2>&1 && set "PY=py -3" 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 where python >nul 2>&1 && set "PY=python"
if not defined PY ( if not defined PY (
echo Python 3 was not found. Install it from https://www.python.org/downloads/ 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.
)
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
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 pause
exit /b 1 exit /b 1
)
%PY% "%~dp0build.py" %*
set "RC=%errorlevel%"
echo.
pause
exit /b %RC%
+221
View File
@@ -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.
+220
View File
@@ -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)
+4 -33
View File
@@ -1,36 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# # build.sh - builds dist/gitdesk with PyInstaller. The real work is done by build.py;
# build.sh - builds gitdesk into a single executable: dist/gitdesk # everything is logged to build.log.
# cd "$(dirname "$0")" || exit 1
# 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")"
PY=$(command -v python3 || command -v python || true) PY=$(command -v python3 || command -v python || true)
[[ -n $PY ]] || { echo "Python 3 wasn't found. Install it first."; exit 1; } [[ -n $PY ]] || { echo "Python 3 wasn't found. Install it first."; exit 1; }
exec "$PY" build.py "$@"
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."
BIN
View File
Binary file not shown.
+361 -4
View File
@@ -117,8 +117,9 @@ from textual.screen import ModalScreen # noqa: E402
from textual.theme import Theme # noqa: E402 from textual.theme import Theme # noqa: E402
from textual.widgets import ( # noqa: E402 from textual.widgets import ( # noqa: E402
Button, Checkbox, ContentSwitcher, DirectoryTree, Footer, Input, Label, ListItem, ListView, 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) # Colours (GitHub Desktop dark-ish)
@@ -442,6 +443,10 @@ class Git:
def file_diff(self, fc: FileChange, has_head: bool) -> str: def file_diff(self, fc: FileChange, has_head: bool) -> str:
if fc.kind == "?": if fc.kind == "?":
return self._untracked_diff(fc.path) 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", "--"] args = ["diff", "HEAD" if has_head else EMPTY_TREE, "--no-ext-diff", "-M", "--"]
if fc.orig: if fc.orig:
args.append(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("~") 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: def file_manager_name() -> str:
return "Explorer" if IS_WIN else ("Finder" if sys.platform == "darwin" else "file manager") 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) style=f"bold {C_BLUE}" if self.checked else C_MUTED)
def on_click(self, event: events.Click) -> None: def on_click(self, event: events.Click) -> None:
if event.button != 1: # right-click: let the row open its menu
return
event.stop() event.stop()
self.checked = not self.checked self.checked = not self.checked
self.post_message(self.Toggled(self)) 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 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: def __init__(self, fc: FileChange, checked: bool) -> None:
super().__init__(classes="filerow") super().__init__(classes="filerow")
self.fc = fc self.fc = fc
self._checked = checked 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: def compose(self) -> ComposeResult:
icon, color, _ = KIND_ICON.get(self.fc.kind, ("●", C_YELLOW, "")) icon, color, _ = KIND_ICON.get(self.fc.kind, ("●", C_YELLOW, ""))
with Horizontal(): with Horizontal():
yield Tick(self._checked, key=self.fc.path, classes="tick") 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") yield Label(Text(icon, style=f"bold {color}"), classes="fkind")
@@ -1418,6 +1528,55 @@ class AddLocalDialog(ChooseFolderMixin, ModalScreen):
self.dismiss(None) 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): class SimpleRow(ListItem):
def __init__(self, value, *lines: Text) -> None: def __init__(self, value, *lines: Text) -> None:
super().__init__() super().__init__()
@@ -1992,6 +2151,10 @@ TabPane { padding: 0; height: 1fr; }
#gitlog { height: 12; border-top: solid #444d56; background: #1f2428; } #gitlog { height: 12; border-top: solid #444d56; background: #1f2428; }
ModalScreen { align: center middle; background: rgba(0, 0, 0, 0.55); } 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%; .dialog { width: 76; max-width: 95%; height: auto; max-height: 90%;
background: #2f363d; border: round #586069; padding: 1 2; } background: #2f363d; border: round #586069; padding: 1 2; }
.dialog.wide { width: 90; } .dialog.wide { width: 90; }
@@ -2041,6 +2204,8 @@ class GitDesk(App):
Binding("l", "toggle_log", "Log"), Binding("l", "toggle_log", "Log"),
Binding("r", "refresh", "Refresh"), Binding("r", "refresh", "Refresh"),
Binding("space", "toggle_file", "Include file", show=False), 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"), Binding("q", "quit", "Quit"),
] ]
@@ -2148,7 +2313,7 @@ class GitDesk(App):
return False return False
APP_KEYS = {"fetch", "pull", "push", "branches", "stash", "stashes", "open_repo", "discard", "view_remote", 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"} "toggle_log", "refresh", "toggle_file", "quit"}
def check_action(self, action: str, parameters) -> Optional[bool]: 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, "")) icon, color, label = KIND_ICON.get(fc.kind, ("●", C_YELLOW, ""))
title = Text(no_wrap=True, overflow="ellipsis") title = Text(no_wrap=True, overflow="ellipsis")
title.append(f"{icon} ", style=f"bold {color}") 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) title.append(f" {label}", style=C_MUTED)
self.query_one("#diff-title", Label).update(title) self.query_one("#diff-title", Label).update(title)
self.query_one("#open-file").display = True self.query_one("#open-file").display = True
@@ -2628,6 +2794,160 @@ class GitDesk(App):
self.update_counts() self.update_counts()
self.update_commit_button() 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: def action_toggle_file(self) -> None:
lv = self.query_one("#files", ListView) lv = self.query_one("#files", ListView)
if self.mode != "changes" or not lv.has_focus or not isinstance(lv.highlighted_child, FileRow): 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: if r.ok:
now = await self.load_state() now = await self.load_state()
present = {f.path for f in now.files} 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 = [] specs = []
for f in files: 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] 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: if not specs:
self.notify("The ticked files have no changes left to commit.", severity="warning") self.notify("The ticked files have no changes left to commit.", severity="warning")
return return
@@ -3638,7 +3979,23 @@ class GitDesk(App):
await self.run_action(action) 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: def main() -> None:
if len(sys.argv) > 1 and sys.argv[1] == "--selftest":
sys.exit(selftest())
if shutil.which("git") is None: if shutil.which("git") is None:
print("gitdesk: git isn't installed or isn't in your PATH.") print("gitdesk: git isn't installed or isn't in your PATH.")
sys.exit(1) sys.exit(1)