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.
- **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
+10 -38
View File
@@ -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 [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.
echo and tick "Add python.exe to PATH" during setup.
pause
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
#
# 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 "$@"
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.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)