initial commit

This commit is contained in:
Treadgold 2026-02-18 13:41:39 +13:00
commit df2724cf25
5 changed files with 919 additions and 0 deletions

719
alpha.py Normal file
View File

@ -0,0 +1,719 @@
import os
import json
from pathlib import Path
import webview
html_content = """
<!DOCTYPE html>
<html>
<head>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
background: #ffffff;
color: #333;
}
.header {
padding: 15px;
border-bottom: 1px solid #ddd;
background: #f5f5f5;
display: flex;
align-items: center;
gap: 10px;
}
.path-input {
flex: 1;
padding: 8px;
font-size: 14px;
border: 1px solid #ccc;
border-radius: 4px;
}
.main-container {
display: flex;
flex: 1;
overflow: hidden;
}
.left-panel {
width: 35%;
border-right: 1px solid #ddd;
overflow-y: auto;
padding: 10px;
background: #ffffff;
}
.right-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.files-view {
flex: 1;
overflow-y: auto;
padding: 10px;
border-bottom: 1px solid #ddd;
background: #ffffff;
}
.output-view {
height: 200px;
display: flex;
flex-direction: column;
border-top: 2px solid #ddd;
}
.output-header {
padding: 8px;
background: #f5f5f5;
border-bottom: 1px solid #ddd;
display: flex;
justify-content: space-between;
align-items: center;
}
.output-content {
flex: 1;
overflow-y: auto;
padding: 10px;
background: #fafafa;
font-family: 'Courier New', monospace;
font-size: 12px;
white-space: pre-wrap;
}
.tree-item {
padding: 4px;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
color: #333;
}
.tree-item:hover {
background: #e3f2fd;
}
.checkbox {
width: 16px;
height: 16px;
border: 2px solid #999;
border-radius: 3px;
margin-right: 6px;
display: inline-block;
flex-shrink: 0;
}
.checkbox.selected {
background: #4CAF50;
border-color: #4CAF50;
}
.checkbox.unselected,
.checkbox.default {
background: #f44336;
border-color: #f44336;
}
.tree-item-text {
flex: 1;
}
.indent {
display: inline-block;
}
.folder-icon {
margin-right: 4px;
}
.expand-icon {
width: 16px;
margin-right: 4px;
cursor: pointer;
user-select: none;
text-align: center;
flex-shrink: 0;
}
.file-item {
padding: 6px;
display: flex;
align-items: center;
color: #333;
}
button {
padding: 8px 16px;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
white-space: nowrap;
}
button:hover {
background: #1976D2;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.small-btn {
padding: 4px 12px;
font-size: 12px;
}
.empty-message {
color: #999;
padding: 20px;
text-align: center;
}
.status-text {
font-size: 12px;
color: #666;
min-width: 120px;
}
.tree-item.unreadable,
.file-item.unreadable {
text-decoration: line-through;
opacity: 0.6;
}
</style>
</head>
<body>
<div class="header">
<input type="text" id="rootPath" class="path-input" placeholder="Enter root directory path..." />
<button id="refreshBtn" onclick="refreshTree()">Refresh</button>
<span id="statusText" class="status-text"></span>
</div>
<div class="main-container">
<div class="left-panel" id="treeView"></div>
<div class="right-panel">
<div class="files-view" id="filesView">
<div class="empty-message">Select a folder from the tree</div>
</div>
<div class="output-view">
<div class="output-header">
<strong>Output Preview</strong>
<div>
<button class="small-btn" onclick="generateOutput()">Generate</button>
<button class="small-btn" onclick="copyOutput()">Copy All</button>
</div>
</div>
<div class="output-content" id="outputContent"></div>
</div>
</div>
</div>
<script>
let fileTree = {};
let selectionState = {}; // path -> 'selected', 'unselected', 'default'
let currentFolder = null;
let collapsedFolders = new Set(); // paths of collapsed folders
let unreadablePaths = new Set(); // paths that could not be read on disk
document.getElementById('rootPath').addEventListener('change', async (e) => {
const path = e.target.value;
if (path) {
setLoading(true);
try {
await loadTree(path);
} finally {
setLoading(false);
}
}
});
async function refreshTree() {
const path = document.getElementById('rootPath').value;
if (path) {
setLoading(true);
try {
await loadTree(path);
} finally {
setLoading(false);
}
}
}
function setLoading(isLoading) {
const status = document.getElementById('statusText');
const rootInput = document.getElementById('rootPath');
const refreshBtn = document.getElementById('refreshBtn');
if (status) {
status.textContent = isLoading ? 'Loading folder tree...' : '';
}
if (rootInput) {
rootInput.disabled = isLoading;
}
if (refreshBtn) {
refreshBtn.disabled = isLoading;
}
}
async function loadTree(path) {
const result = await pywebview.api.load_tree(path);
if (result.error) {
alert(result.error);
return;
}
fileTree = result.tree;
selectionState = {};
unreadablePaths = new Set(result.unreadable || []);
initializeCollapsedFolders(fileTree);
// Ensure the root node is expanded by default
collapsedFolders.delete('');
renderTree();
// Automatically select the root directory to show its files
selectFolder('', fileTree);
}
function getEffectiveState(path) {
if (selectionState[path]) {
return selectionState[path];
}
// Check parent states
const parts = path.split(/[\\/]/);
// We need to check up to the root (empty string path)
for (let i = parts.length - 1; i >= 0; i--) {
const parentPath = parts.slice(0, i).join('/');
if (selectionState[parentPath]) {
const state = selectionState[parentPath];
if (state === 'selected' || state === 'unselected') {
return state;
}
}
}
// Default logical state is "default" (no explicit selection)
// but we style it visually as red ("don't copy")
return 'default';
}
function initializeCollapsedFolders(tree) {
collapsedFolders.clear();
function walk(node, basePath) {
for (const [name, data] of Object.entries(node)) {
if (name === '_files') continue;
const fullPath = basePath ? `${basePath}/${name}` : name;
collapsedFolders.add(fullPath);
if (data && typeof data === 'object') {
walk(data, fullPath);
}
}
}
walk(tree, '');
}
function renderTree() {
const container = document.getElementById('treeView');
container.innerHTML = '';
// Extract a name for the root node from the input path
const rootPathInput = document.getElementById('rootPath').value;
let rootName = 'Root';
if (rootPathInput) {
rootName = rootPathInput.split(/[\\/]/).pop() || rootPathInput;
}
// Manually render the root node so it can be selected
const rootPath = '';
const rootItem = document.createElement('div');
rootItem.className = 'tree-item';
// Indent 0 for root
const indent = document.createElement('span');
indent.className = 'indent';
indent.style.width = '0px';
rootItem.appendChild(indent);
// Expand/Collapse for root
const expandIcon = document.createElement('span');
expandIcon.className = 'expand-icon';
const isCollapsed = collapsedFolders.has(rootPath);
expandIcon.textContent = isCollapsed ? '' : '';
expandIcon.onclick = (e) => {
e.stopPropagation();
toggleCollapse(rootPath);
};
rootItem.appendChild(expandIcon);
// Checkbox for root
const checkbox = document.createElement('span');
checkbox.className = `checkbox ${getEffectiveState(rootPath)}`;
checkbox.onclick = (e) => {
e.stopPropagation();
toggleSelection(rootPath);
};
rootItem.appendChild(checkbox);
const icon = document.createElement('span');
icon.className = 'folder-icon';
icon.textContent = '📁';
rootItem.appendChild(icon);
const text = document.createElement('span');
text.className = 'tree-item-text';
text.textContent = rootName;
text.onclick = () => selectFolder(rootPath, fileTree);
rootItem.appendChild(text);
container.appendChild(rootItem);
// Render children of root if not collapsed
if (!isCollapsed) {
// We pass level 1 for children
renderNode(fileTree, container, 1, rootPath);
}
}
function renderNode(node, container, level, path) {
for (const [name, data] of Object.entries(node)) {
if (name === '_files') continue;
const fullPath = path ? `${path}/${name}` : name;
const item = document.createElement('div');
item.className = 'tree-item';
const isUnreadable = unreadablePaths.has(fullPath);
if (isUnreadable) {
item.classList.add('unreadable');
item.title = 'Some or all contents of this folder could not be read';
}
const indent = document.createElement('span');
indent.className = 'indent';
indent.style.width = (level * 20) + 'px';
item.appendChild(indent);
// Add expand/collapse icon
const expandIcon = document.createElement('span');
expandIcon.className = 'expand-icon';
const isCollapsed = collapsedFolders.has(fullPath);
expandIcon.textContent = isCollapsed ? '' : '';
expandIcon.onclick = (e) => {
e.stopPropagation();
toggleCollapse(fullPath);
};
item.appendChild(expandIcon);
const checkbox = document.createElement('span');
checkbox.className = `checkbox ${getEffectiveState(fullPath)}`;
if (isUnreadable) {
checkbox.style.opacity = '0.5';
checkbox.style.cursor = 'not-allowed';
}
checkbox.onclick = (e) => {
e.stopPropagation();
if (isUnreadable) {
return; // Don't allow toggling selection for unreadable folders
}
toggleSelection(fullPath);
};
item.appendChild(checkbox);
const icon = document.createElement('span');
icon.className = 'folder-icon';
icon.textContent = '📁';
item.appendChild(icon);
const text = document.createElement('span');
text.className = 'tree-item-text';
text.textContent = name;
text.onclick = () => selectFolder(fullPath, data);
item.appendChild(text);
container.appendChild(item);
// Only render children if not collapsed
if (!isCollapsed && typeof data === 'object' && data !== null) {
renderNode(data, container, level + 1, fullPath);
}
}
}
function toggleCollapse(path) {
if (collapsedFolders.has(path)) {
collapsedFolders.delete(path);
} else {
collapsedFolders.add(path);
}
renderTree();
}
function toggleSelection(path) {
const current = selectionState[path] || 'default';
if (current === 'default' || current === 'unselected') {
selectionState[path] = 'selected';
} else {
selectionState[path] = 'unselected';
}
renderTree();
if (currentFolder && currentFolder.path === path) {
renderFiles(currentFolder.path, currentFolder.data);
}
}
function selectFolder(path, data) {
currentFolder = { path, data };
renderFiles(path, data);
}
function renderFiles(path, data) {
const container = document.getElementById('filesView');
container.innerHTML = '';
const items = [];
// Helper to correctly form path without leading slash if root
const makePath = (name) => path ? `${path}/${name}` : name;
// Add folders
for (const [name, subData] of Object.entries(data)) {
if (name !== '_files' && typeof subData === 'object') {
items.push({ name, type: 'folder', path: makePath(name) });
}
}
// Add files
if (data._files) {
for (const fileName of data._files) {
items.push({ name: fileName, type: 'file', path: makePath(fileName) });
}
}
if (items.length === 0) {
container.innerHTML = '<div class="empty-message">Empty folder</div>';
return;
}
items.forEach(item => {
const div = document.createElement('div');
div.className = 'file-item';
const isUnreadable = unreadablePaths.has(item.path);
if (isUnreadable) {
div.classList.add('unreadable');
div.title = 'This item could not be read (locked or permission denied)';
}
const checkbox = document.createElement('span');
checkbox.className = `checkbox ${getEffectiveState(item.path)}`;
if (isUnreadable) {
checkbox.style.opacity = '0.5';
checkbox.style.cursor = 'not-allowed';
}
checkbox.onclick = (e) => {
e.stopPropagation();
if (isUnreadable) {
return; // Don't allow toggling selection for unreadable items
}
toggleSelection(item.path);
// Re-render the files view to update checkbox colors
renderFiles(path, data);
};
div.appendChild(checkbox);
const icon = document.createElement('span');
icon.textContent = item.type === 'folder' ? '📁 ' : '📄 ';
div.appendChild(icon);
const text = document.createElement('span');
text.textContent = item.name;
div.appendChild(text);
container.appendChild(div);
});
}
async function generateOutput() {
const output = document.getElementById('outputContent');
output.textContent = 'Generating...';
const rootPath = document.getElementById('rootPath').value;
const result = await pywebview.api.generate_output(rootPath, selectionState);
if (result.error) {
output.textContent = 'Error: ' + result.error;
} else {
output.textContent = result.content;
}
}
function copyOutput() {
const output = document.getElementById('outputContent');
const text = output.textContent;
if (!text || text === 'Generating...') {
alert('No output to copy');
return;
}
// Prefer modern Clipboard API when available
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
alert('Copied to clipboard!');
}).catch(err => {
// Fallback for environments where Clipboard API is not permitted
if (fallbackCopyText(text)) {
alert('Copied to clipboard!');
} else {
alert('Failed to copy: ' + err);
}
});
} else {
// Older or restricted webview engines: use execCommand-based fallback
if (fallbackCopyText(text)) {
alert('Copied to clipboard!');
} else {
alert('Failed to copy: Clipboard API not available');
}
}
}
function fallbackCopyText(text) {
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textarea);
return successful;
} catch (err) {
return false;
}
}
</script>
</body>
</html>
"""
class API:
def load_tree(self, root_path):
try:
root = Path(root_path)
if not root.exists() or not root.is_dir():
return {"error": "Invalid directory path"}
unreadable = []
tree = self._build_tree(root, root, "", unreadable)
return {"tree": tree, "unreadable": unreadable}
except Exception as e:
return {"error": str(e)}
def _build_tree(self, root, path, relative_path, unreadable):
tree = {}
files = []
# Try to list directory contents; if we can't, record as unreadable and treat as empty
try:
items = list(path.iterdir())
except (PermissionError, OSError):
rel = relative_path or path.name
unreadable.append(rel.replace("\\", "/"))
return tree
# Sort items and handle errors per entry so one bad file/dir
# doesn't cause the whole folder to appear empty
for item in sorted(items, key=lambda x: (not x.is_dir(), x.name.lower())):
item_rel = f"{relative_path}/{item.name}" if relative_path else item.name
try:
if item.is_dir():
tree[item.name] = self._build_tree(root, item, item_rel, unreadable)
else:
files.append(item.name)
except (PermissionError, OSError):
# Skip entries we can't stat or descend into, but record them
unreadable.append(item_rel.replace("\\", "/"))
continue
if files:
tree['_files'] = files
return tree
def generate_output(self, root_path, selection_state):
try:
root = Path(root_path)
if not root.exists():
return {"error": "Invalid directory path"}
output = []
self._collect_files(root, root, selection_state, output)
content = "\n\n".join(output)
return {"content": content}
except Exception as e:
return {"error": str(e)}
def _collect_files(self, root, current_path, selection_state, output):
relative_path = str(current_path.relative_to(root)).replace('\\', '/')
# We always walk the directory tree, but will only include files
# whose effective state is "selected".
try:
items = list(current_path.iterdir())
except (PermissionError, OSError):
return
for item in sorted(items, key=lambda x: (not x.is_dir(), x.name.lower())):
item_relative = str(item.relative_to(root)).replace('\\', '/')
item_state = self._get_effective_state(item_relative, selection_state)
try:
if item.is_dir():
self._collect_files(root, item, selection_state, output)
elif item.is_file():
# Only include files that are effectively selected
if item_state != 'selected':
continue
try:
with open(item, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
file_entry = f"=== {item_relative} ===\n{content}"
output.append(file_entry)
except Exception:
# Skip files we can't open/read
pass
except (PermissionError, OSError):
# Skip entries we can't stat or descend into
continue
def _get_effective_state(self, path, selection_state):
if path in selection_state:
return selection_state[path]
# Check parent states
parts = path.split('/')
# range(len(parts) - 1, 0, -1) stops before 0.
# We need it to include 0 to check '' (root)
for i in range(len(parts) - 1, -1, -1):
parent_path = '/'.join(parts[:i])
if parent_path in selection_state:
state = selection_state[parent_path]
if state in ['selected', 'unselected']:
return state
# Logical default: no explicit or inherited selection
return 'default'
def main():
api = API()
window = webview.create_window(
'File Tree Selector',
html=html_content,
js_api=api,
width=1200,
height=800
)
webview.start()
if __name__ == '__main__':
main()

78
readme.md Normal file
View File

@ -0,0 +1,78 @@
# File Tree Selector
A desktop GUI tool for browsing a directory tree, selecting files/folders, and exporting their contents as a single concatenated text block — useful for feeding code into LLMs.
## What it does
- Displays a navigable file tree for any directory you specify
- Green checkbox = include in output, red = exclude
- Selection cascades: mark a folder green and all its children are included unless individually excluded
- Unreadable/permission-denied files are shown with strikethrough and cannot be selected
- "Generate" concatenates all selected files into a preview pane; "Copy All" puts it on your clipboard
## Requirements
- Python 3.8+
- `pywebview` and its platform dependencies (see below)
### Linux (additional system packages required)
pywebview on Linux requires GTK and WebKit2 — these cannot be installed via pip:
```bash
# Ubuntu/Debian
sudo apt install python3-gi python3-gi-cairo gir1.2-webkit2-4.0
# If on a newer distro (Ubuntu 24+), try:
sudo apt install python3-gi python3-gi-cairo gir1.2-webkit2-4.1
```
### Windows
No additional system packages required. pywebview uses the built-in WebView2 runtime (included with Windows 10/11).
### macOS
No additional system packages required. pywebview uses the built-in WebKit.
## Running the app
### Linux / macOS
```bash
bash run.sh
```
The script will create a `.venv`, install Python dependencies, and launch the app. On Linux, it checks for GTK dependencies first and exits with instructions if they're missing.
### Windows
```powershell
.\run.ps1
```
Run from PowerShell. If you get an execution policy error:
```powershell
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
```
## Project structure
```
.
├── alpha.py # Main application (latest version)
├── run.sh # Setup + launch script for Linux/macOS
├── run.ps1 # Setup + launch script for Windows
├── requirements.txt # Python dependencies
└── README.md
```
## Usage
1. Launch the app
2. Type (or paste) an absolute path into the path bar and press Enter
3. The left panel shows the folder tree; click a folder to view its contents on the right
4. Click a checkbox to toggle include/exclude — red = excluded, green = included
5. Use the root folder checkbox to include/exclude everything at once, then carve out exceptions
6. Click **Generate** to preview the output, then **Copy All** to copy to clipboard

BIN
requirements.txt Normal file

Binary file not shown.

42
run.ps1 Normal file
View File

@ -0,0 +1,42 @@
# PowerShell script to ensure virtual environment exists, install dependencies, and run alpha.py
# Get the script directory
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $scriptDir
# Path to virtual environment activation script
$venvActivate = ".venv\Scripts\Activate.ps1"
# Function to create virtual environment and install dependencies
function Setup-Venv {
Write-Host "Creating virtual environment..." -ForegroundColor Green
python -m venv .venv
if (-Not (Test-Path $venvActivate)) {
Write-Host "Error: Failed to create virtual environment." -ForegroundColor Red
exit 1
}
Write-Host "Activating virtual environment..." -ForegroundColor Green
& $venvActivate
if (Test-Path "requirements.txt") {
Write-Host "Installing dependencies from requirements.txt..." -ForegroundColor Green
pip install --upgrade pip
pip install -r requirements.txt
} else {
Write-Host "No requirements.txt found, skipping dependency installation." -ForegroundColor Yellow
}
}
# Check if virtual environment exists
if (Test-Path $venvActivate) {
Write-Host "Activating virtual environment..." -ForegroundColor Green
& $venvActivate
} else {
Setup-Venv
}
# Run the application
Write-Host "Running alpha.py..." -ForegroundColor Green
python alpha.py

80
run.sh Normal file
View File

@ -0,0 +1,80 @@
#!/bin/bash
# Cross-platform helper to set up .venv, install dependencies, and run alpha.py
set -e
# Get the directory of the script
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
VENV_DIR=".venv"
# List of essential Python packages
REQUIRED_PACKAGES=("pywebview")
# Function to check Linux GTK dependencies
check_gtk_linux() {
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
# Try importing gi in python
python3 - <<EOF 2>/dev/null
try:
import gi
except ImportError:
import sys
sys.exit(1)
EOF
if [[ $? -ne 0 ]]; then
echo -e "\e[31mError: GTK dependencies missing on Linux.\e[0m"
echo "Install them with:"
echo " sudo apt install python3-gi python3-gi-cairo gir1.2-webkit2-4.0"
exit 1
fi
fi
}
# Function to create virtual environment and install dependencies
setup_venv() {
echo -e "\e[32mCreating virtual environment...\e[0m"
python3 -m venv "$VENV_DIR"
if [ ! -f "$VENV_DIR/bin/activate" ]; then
echo -e "\e[31mError: Failed to create virtual environment.\e[0m"
exit 1
fi
echo -e "\e[32mActivating virtual environment...\e[0m"
source "$VENV_DIR/bin/activate"
# Upgrade pip first
pip install --upgrade pip
# Install pywebview and other dependencies
for pkg in "${REQUIRED_PACKAGES[@]}"; do
pip install "$pkg"
done
# Install packages from requirements.txt if it exists
if [ -f "requirements.txt" ]; then
echo -e "\e[32mInstalling dependencies from requirements.txt...\e[0m"
pip install -r requirements.txt
else
echo -e "\e[33mNo requirements.txt found, skipping additional dependencies.\e[0m"
fi
echo -e "\e[32mVirtual environment ready.\e[0m"
}
# 1⃣ Check GTK dependencies on Linux
check_gtk_linux
# 2⃣ Activate existing venv or create one
if [ -f "$VENV_DIR/bin/activate" ]; then
echo -e "\e[32mActivating existing virtual environment...\e[0m"
source "$VENV_DIR/bin/activate"
else
setup_venv
fi
# 3⃣ Run the application
echo -e "\e[32mRunning alpha.py...\e[0m"
python alpha.py