Skip to content

Instantly share code, notes, and snippets.

@firedynasty
Created May 28, 2026 22:27
Show Gist options
  • Select an option

  • Save firedynasty/0ca0d01c8a93fefd03fd4faff60348b2 to your computer and use it in GitHub Desktop.

Select an option

Save firedynasty/0ca0d01c8a93fefd03fd4faff60348b2 to your computer and use it in GitHub Desktop.
loading directories

Directory Picker

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.

Features

  • 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

Usage

  1. Copy the folder path (e.g. from terminal: pwd | pbcopy)
  2. Click Open Folder and pick that folder
  3. Navigate subdirectories by clicking
  4. 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.

Code

<!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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment