Browser-based folder navigator with clipboard path integration. Uses the File System Access API to scan and browse directory trees, then copies full filesystem paths to clipboard.
- Open any folder and browse its subdirectory tree
- Auto-reads clipboard on open to set the root path (copy the path before clicking Open)
- Search across all directories
- Breadcrumb navigation
- Copy full path to clipboard
- Copy the folder path (e.g. from terminal:
pwd | pbcopy) - Click Open Folder and pick that folder
- Navigate subdirectories by clicking
- Click Copy Path to get the full filesystem path
The root path auto-populates from your clipboard β if it ends with the opened folder name, it uses the parent as the root.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Directory Picker</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #111;
color: #e0e0e0;
padding: 16px;
max-width: 700px;
margin: 0 auto;
}
h1 { font-size: 18px; margin-bottom: 12px; color: #f97316; }
.root-row {
display: flex;
gap: 8px;
margin-bottom: 12px;
align-items: center;
}
.root-row label { font-size: 13px; white-space: nowrap; color: #aaa; }
.root-row input {
flex: 1;
background: #222;
border: 1px solid #444;
color: #fff;
padding: 6px 10px;
border-radius: 4px;
font-size: 13px;
font-family: monospace;
}
.actions { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
.btn {
padding: 8px 16px;
border: none;
border-radius: 5px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.btn:hover { opacity: 0.85; }
.btn-primary { background: #f97316; color: #fff; }
.btn-secondary { background: #333; color: #ccc; border: 1px solid #555; }
.breadcrumb {
display: flex;
flex-wrap: wrap;
gap: 2px;
align-items: center;
margin-bottom: 12px;
font-size: 13px;
min-height: 24px;
}
.breadcrumb span { color: #666; margin: 0 2px; }
.crumb {
background: none;
border: none;
color: #60a5fa;
cursor: pointer;
font-size: 13px;
padding: 2px 4px;
border-radius: 3px;
}
.crumb:hover { background: #222; text-decoration: underline; }
.crumb.current { color: #f97316; font-weight: 600; cursor: default; }
.crumb.current:hover { background: none; text-decoration: none; }
.dir-list {
border: 1px solid #333;
border-radius: 6px;
background: #1a1a1a;
max-height: 60vh;
overflow-y: auto;
}
.dir-item {
display: flex;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid #252525;
cursor: pointer;
transition: background 0.1s;
gap: 8px;
}
.dir-item:last-child { border-bottom: none; }
.dir-item:hover { background: #252525; }
.dir-item .folder-icon { color: #f97316; font-size: 14px; flex-shrink: 0; }
.dir-item .name { flex: 1; font-size: 14px; font-family: monospace; }
.dir-item .arrow { color: #555; font-size: 12px; flex-shrink: 0; }
.dir-item .child-count {
color: #666;
font-size: 11px;
flex-shrink: 0;
}
.dir-item.leaf .arrow { visibility: hidden; }
.select-bar {
display: flex;
gap: 8px;
align-items: center;
margin-top: 12px;
padding: 10px 12px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
}
.select-bar .path {
flex: 1;
font-family: monospace;
font-size: 13px;
color: #f97316;
word-break: break-all;
}
.select-bar .btn { flex-shrink: 0; }
.toast {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: #f97316;
color: #fff;
padding: 8px 20px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.toast.show { opacity: 1; }
.search-row {
display: flex;
gap: 8px;
margin-bottom: 12px;
align-items: center;
}
.search-row input {
flex: 1;
background: #222;
border: 1px solid #444;
color: #fff;
padding: 6px 10px;
border-radius: 4px;
font-size: 13px;
font-family: monospace;
}
.search-row input::placeholder { color: #666; }
.search-results {
border: 1px solid #444;
border-radius: 6px;
background: #1a1a1a;
max-height: 40vh;
overflow-y: auto;
margin-bottom: 12px;
}
.search-results .dir-item .match-path {
font-size: 12px;
color: #888;
font-family: monospace;
margin-left: 4px;
}
.search-results .dir-item .match-path mark {
background: none;
color: #f97316;
font-weight: 700;
}
.status { font-size: 12px; color: #888; margin-bottom: 12px; }
.empty-msg { padding: 20px; text-align: center; color: #666; font-size: 13px; }
</style>
</head>
<body>
<h1>Directory Picker</h1>
<div class="root-row">
<label>Root path: <span style="color:#666; font-weight:normal;">(copy & paste into the opener)</span></label>
<input type="text" id="rootPath" placeholder="/Users/stanleytan/Documents/notes" />
</div>
<div class="actions">
<button class="btn btn-primary" id="openBtn">Open Folder</button>
<button class="btn btn-secondary" id="clearBtn">Clear</button>
</div>
<div class="status" id="status"></div>
<div class="search-row" id="searchRow" style="display:none;">
<input type="text" id="searchInput" placeholder="Search directories..." />
</div>
<div class="search-results" id="searchResults" style="display:none;"></div>
<div class="breadcrumb" id="breadcrumb"></div>
<div class="dir-list" id="dirList" style="display:none;"></div>
<div class="select-bar" id="selectBar" style="display:none;">
<div class="path" id="selectedPath"></div>
<button class="btn btn-primary" id="copyBtn">Copy Path</button>
</div>
<div class="toast" id="toast">Copied!</div>
<script>
const rootPathInput = document.getElementById('rootPath');
const openBtn = document.getElementById('openBtn');
const clearBtn = document.getElementById('clearBtn');
const statusEl = document.getElementById('status');
const searchRow = document.getElementById('searchRow');
const searchInput = document.getElementById('searchInput');
const searchResults = document.getElementById('searchResults');
const breadcrumbEl = document.getElementById('breadcrumb');
const dirListEl = document.getElementById('dirList');
const selectBar = document.getElementById('selectBar');
const selectedPathEl = document.getElementById('selectedPath');
const copyBtn = document.getElementById('copyBtn');
const toastEl = document.getElementById('toast');
rootPathInput.value = '';
let tree = null; // nested object: { name, children: [...] }
let currentPath = []; // array of directory names representing current drill-down
openBtn.addEventListener('click', async () => {
try {
const handle = await window.showDirectoryPicker({ mode: 'read' });
statusEl.textContent = 'Scanning directories...';
openBtn.disabled = true;
tree = await scanDir(handle);
openBtn.disabled = false;
statusEl.textContent = `Scanned ${countDirs(tree)} directories.`;
// Auto-set root path from clipboard
try {
const clip = (await navigator.clipboard.readText()).trim().replace(/\/+$/, '');
if (clip.endsWith('/' + handle.name)) {
// Clipboard has full path to this folder β use parent as root
rootPathInput.value = clip.slice(0, -(handle.name.length + 1));
} else if (clip.startsWith('/')) {
// Clipboard has some path β use as-is
rootPathInput.value = clip;
}
} catch (e) { /* clipboard permission denied, ignore */ }
rebuildSearchIndex();
searchRow.style.display = 'flex';
currentPath = [];
render();
} catch (e) {
openBtn.disabled = false;
if (e.name !== 'AbortError') {
statusEl.textContent = 'Error: ' + e.message;
}
}
});
clearBtn.addEventListener('click', () => {
tree = null;
currentPath = [];
allPaths = [];
dirListEl.style.display = 'none';
selectBar.style.display = 'none';
searchRow.style.display = 'none';
searchResults.style.display = 'none';
searchInput.value = '';
breadcrumbEl.innerHTML = '';
statusEl.textContent = '';
});
copyBtn.addEventListener('click', () => {
const path = buildFullPath();
navigator.clipboard.writeText(path);
showToast('Copied!');
});
async function scanDir(handle) {
const node = { name: handle.name, children: [] };
const entries = [];
for await (const entry of handle.values()) {
if (entry.kind === 'directory' && !entry.name.startsWith('.')) {
entries.push(entry);
}
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
node.children.push(await scanDir(entry));
}
return node;
}
function countDirs(node) {
let c = 1;
for (const child of node.children) c += countDirs(child);
return c;
}
function getNodeAtPath(path) {
let node = tree;
for (const seg of path) {
node = node.children.find(c => c.name === seg);
if (!node) return null;
}
return node;
}
// Search: collect all directory paths from tree as arrays of segments
function collectPaths(node, prefix) {
const results = [];
for (const child of node.children) {
const path = [...prefix, child.name];
results.push(path);
results.push(...collectPaths(child, path));
}
return results;
}
let allPaths = []; // cached flat list of path arrays
function rebuildSearchIndex() {
if (!tree) { allPaths = []; return; }
allPaths = collectPaths(tree, []);
}
searchInput.addEventListener('input', () => {
const q = searchInput.value.trim().toLowerCase();
if (!q) {
searchResults.style.display = 'none';
searchResults.innerHTML = '';
return;
}
const terms = q.split(/\s+/);
const matches = allPaths.filter(p => {
const full = p.join('/').toLowerCase();
return terms.every(t => full.includes(t));
}).slice(0, 50); // cap at 50 results
searchResults.innerHTML = '';
if (matches.length === 0) {
searchResults.style.display = 'block';
searchResults.innerHTML = '<div class="empty-msg">No matches</div>';
return;
}
searchResults.style.display = 'block';
for (const pathArr of matches) {
const item = document.createElement('div');
item.className = 'dir-item';
const icon = document.createElement('span');
icon.className = 'folder-icon';
icon.textContent = 'π';
const pathSpan = document.createElement('span');
pathSpan.className = 'match-path';
// Highlight matching terms
let display = pathArr.join('/');
for (const t of terms) {
const re = new RegExp(`(${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
display = display.replace(re, '<mark>$1</mark>');
}
pathSpan.innerHTML = display;
item.appendChild(icon);
item.appendChild(pathSpan);
item.addEventListener('click', () => {
currentPath = [...pathArr];
searchInput.value = '';
searchResults.style.display = 'none';
render();
});
searchResults.appendChild(item);
}
});
function buildFullPath() {
const root = rootPathInput.value.replace(/\/+$/, '');
const parts = [root, tree.name, ...currentPath];
return parts.join('/');
}
function render() {
const node = getNodeAtPath(currentPath);
if (!node) return;
// Breadcrumb
breadcrumbEl.innerHTML = '';
const rootCrumb = document.createElement('button');
rootCrumb.className = 'crumb' + (currentPath.length === 0 ? ' current' : '');
rootCrumb.textContent = tree.name;
rootCrumb.onclick = () => { currentPath = []; render(); };
breadcrumbEl.appendChild(rootCrumb);
for (let i = 0; i < currentPath.length; i++) {
const sep = document.createElement('span');
sep.textContent = '/';
breadcrumbEl.appendChild(sep);
const crumb = document.createElement('button');
crumb.className = 'crumb' + (i === currentPath.length - 1 ? ' current' : '');
crumb.textContent = currentPath[i];
const depth = i + 1;
crumb.onclick = () => { currentPath = currentPath.slice(0, depth); render(); };
breadcrumbEl.appendChild(crumb);
}
// Directory list
dirListEl.style.display = 'block';
dirListEl.innerHTML = '';
if (node.children.length === 0) {
dirListEl.innerHTML = '<div class="empty-msg">No subdirectories</div>';
} else {
for (const child of node.children) {
const item = document.createElement('div');
item.className = 'dir-item' + (child.children.length === 0 ? ' leaf' : '');
const icon = document.createElement('span');
icon.className = 'folder-icon';
icon.textContent = 'π';
const name = document.createElement('span');
name.className = 'name';
name.textContent = child.name;
const count = document.createElement('span');
count.className = 'child-count';
count.textContent = child.children.length > 0 ? `(${child.children.length})` : '';
const arrow = document.createElement('span');
arrow.className = 'arrow';
arrow.textContent = 'βΈ';
item.appendChild(icon);
item.appendChild(name);
item.appendChild(count);
item.appendChild(arrow);
item.addEventListener('click', () => {
currentPath.push(child.name);
render();
});
dirListEl.appendChild(item);
}
}
// Select bar
selectBar.style.display = 'flex';
selectedPathEl.textContent = buildFullPath();
}
function showToast(msg) {
toastEl.textContent = msg;
toastEl.classList.add('show');
setTimeout(() => toastEl.classList.remove('show'), 1500);
}
</script>
</body>
</html>