initial commit
This commit is contained in:
commit
df2724cf25
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
Binary file not shown.
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue