add gitignore
This commit is contained in:
1 parent
0d1ff65479
commit
f2c44dbc0d
8 files changed
+840
-78
No files matched your search
@@ -0,0 +1 @@
|
||||
/.build/
|
||||
@@ -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
|
||||
@@ -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%
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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 "$@"
|
||||
Vendored
BIN
Binary file not shown.
+361
-4
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user