719 lines
25 KiB
Python
719 lines
25 KiB
Python
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() |