PageSourceSearch

https://smrtbld.ai/js/components/OneDrivePanel.js

js smrtbld.ai collected 2026-10-05 15:07:09 UTC 58,015 bytes, 1,330 lines download raw bytes

1/**
2 * OneDrivePanel — Sliding file browser panel (follows NotificationCenter pattern).
3 *
4 * Faithful OneDrive UI with Sort, View modes (List/Compact/Tiles),
5 * column headers, and Office-branded file type icons.
6 *
7 * Usage:
8 *   import { OneDrivePanel } from '../components/OneDrivePanel.js';
9 *   await OneDrivePanel.init();
10 *   OneDrivePanel.toggle();
11 *
12 * ⚠️ DESIGN BIBLE AUDIT — SKIP THIS FILE.
13 * This component intentionally mirrors Microsoft's OneDrive UI for user familiarity.
14 * innerHTML usage here is by design (matching Microsoft's chrome). Do not refactor
15 * to JsRender templates or flag as a Design Bible violation.
16 */
17
18import { msGraph } from '../graph/auth.js';
19import { oneDrive } from '../graph/onedrive.js';
20import { loadTemplates, renderToString } from '../utils/templates.js';
21import { safeAsync } from '../utils/async.js';
22import { ApiClient } from '../api/base.js';
23import { importToProjectFolder, importToTask, importToSubmittal } from '../api/onedrive-import.js';
24import { setLoading, escapeHtml, runWithBusyButton, afterRender } from '../utils/dom.js';
25import { documentApi } from '../api/project-documents.js';
26import { showModal } from '../utils/modal.js';
27import { SearchableDropdown } from './SearchableDropdown.js';
28import { getMicrosoftFileIcon, getMicrosoftFileColor } from '../utils/file-icons-microsoft.js';
29import { flagFieldError } from '../utils/form-validation.js';
30import { captureDurableSettlementOwner, canContinueDurableSettlement } from '../utils/durable-settlement-owner.js';
31
32const api = new ApiClient();
33
34let _initialized = false;
35let _open = false;
36let _currentFolderId = null; // null = root
37let _breadcrumbs = []; // [{ id, name }]
38let _searchTimeout = null;
39let _currentItems = []; // cached for re-sort without re-fetch
40let _sortState = { column: 'name', direction: 'asc' };
41let _viewMode = 'list'; // 'list' | 'compact' | 'tiles'
42let _folderLoadGeneration = 0; // the latest _loadFolder owns #onedrive-file-list (CA-620)
43
44// Per-file action menu (portalled to <body> — see _showItemMenu for why)
45let _itemMenuEl = null;
46let _itemMenuAnchor = null;
47let _itemMenuCleanup = null;
48let _menuTemplatesLoaded = false;
49
50// Owns the document-level listeners _bindEvents attaches. They are anonymous closures, so
51// removeEventListener cannot reach them — without this they would outlive the authenticated
52// session onto the login page. Aborted by destroy(), which the router calls at the boundary.
53let _eventsAbort = null;
54let _createUiAbort = null;
55
56/** Width of the action menu in px — must track the `w-52` class below. */
57const ITEM_MENU_WIDTH = 208;
58
59/** Create-action key → the domain it creates. Mirrors #tpl-external-attach-menu's data-action suffixes. */
60const CREATE_LABELS = { rfi: 'RFI', chg: 'Change Request', si: 'Site Instruction', submittal: 'Submittal' };
61
62// OneDrive cloud SVG (reused in breadcrumbs)
63const OD_CLOUD_SVG_SM = `<svg viewBox="0 0 24 24" class="w-3.5 h-3.5 shrink-0" fill="#0078D4" aria-hidden="true"><path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z"/></svg>`;
64
65export const OneDrivePanel = {
66
67    async init() {
68        if (_initialized) return;
69
70        // Restore persisted preferences
71        _viewMode = localStorage.getItem('od_viewMode') || 'list';
72        try {
73            const saved = localStorage.getItem('od_sort');
74            if (saved) _sortState = JSON.parse(saved);
75        } catch { /* use defaults */ }
76
77        // Load template HTML (lands in a hidden container)
78        await loadTemplates('components/onedrive-panel');
79
80        // Move panel + backdrop + import modal out of hidden container into body so they render
81        const panel = document.getElementById('onedrive-panel');
82        const backdrop = document.getElementById('onedrive-backdrop');
83        const importModal = document.getElementById('onedrive-import-modal');
84        if (panel) document.body.appendChild(panel);
85        if (backdrop) document.body.appendChild(backdrop);
86        if (importModal) document.body.appendChild(importModal);
87
88        _initialized = true;
89
90        this._bindEvents();
91        this._updateSortMenuState();
92        this._updateViewMenuState();
93        this._updateColumnHeaders();
94    },
95
96    toggle() {
97        if (_open) this.close();
98        else this.open();
99    },
100
101    async open() {
102        if (!_initialized) await this.init();
103
104        const panel = document.getElementById('onedrive-panel');
105        const backdrop = document.getElementById('onedrive-backdrop');
106        if (!panel) return;
107
108        panel.classList.remove('translate-x-full');
109        if (backdrop) {
110            backdrop.classList.remove('opacity-0', 'pointer-events-none');
111            backdrop.classList.add('opacity-100', 'pointer-events-auto');
112        }
113        _open = true;
114        _createUiAbort?.abort();
115        _createUiAbort = new AbortController();
116        const openSignal = _createUiAbort.signal;
117
118        // Check connection and load
119        const { connected, email } = await msGraph.checkConnection();
120        if (openSignal.aborted || !_open || document.getElementById('onedrive-panel') !== panel) return;
121        const emailEl = document.getElementById('onedrive-account-email');
122        const fileList = document.getElementById('onedrive-file-list');
123        const notConnected = document.getElementById('onedrive-not-connected');
124        const breadcrumbs = document.getElementById('onedrive-breadcrumbs');
125        const searchBar = document.querySelector('#onedrive-search')?.closest('div.border-b');
126        const toolbar = document.getElementById('onedrive-toolbar');
127        const colHeaders = document.getElementById('onedrive-column-headers');
128
129        if (!connected) {
130            if (fileList) fileList.classList.add('hidden');
131            if (breadcrumbs) breadcrumbs.classList.add('hidden');
132            if (searchBar) searchBar.classList.add('hidden');
133            if (toolbar) toolbar.classList.add('hidden');
134            if (colHeaders) colHeaders.classList.add('hidden');
135            if (notConnected) notConnected.classList.remove('hidden');
136            if (emailEl) emailEl.textContent = '';
137            return;
138        }
139
140        // Connected
141        if (notConnected) notConnected.classList.add('hidden');
142        if (fileList) fileList.classList.remove('hidden');
143        if (breadcrumbs) breadcrumbs.classList.remove('hidden');
144        if (searchBar) searchBar.classList.remove('hidden');
145        if (toolbar) toolbar.classList.remove('hidden');
146        this._updateColumnHeadersVisibility();
147        if (emailEl) emailEl.textContent = email || '';
148
149        // Load root if we haven't navigated yet
150        if (_currentFolderId === null && !_currentItems.length) {
151            await this.navigateToRoot();
152        }
153    },
154
155    close() {
156        _createUiAbort?.abort();
157        _createUiAbort = null;
158        const panel = document.getElementById('onedrive-panel');
159        const backdrop = document.getElementById('onedrive-backdrop');
160        if (!panel) return;
161
162        panel.classList.add('translate-x-full');
163        if (backdrop) {
164            backdrop.classList.remove('opacity-100', 'pointer-events-auto');
165            backdrop.classList.add('opacity-0', 'pointer-events-none');
166        }
167        _open = false;
168
169        // Close any open menus
170        this._closeSortMenu();
171        this._closeViewMenu();
172        this._closeItemMenu();
173    },
174
175    async navigateToRoot() {
176        _currentFolderId = null;
177        _breadcrumbs = [];
178        this._renderBreadcrumbs();
179        await this._loadFolder(null);
180    },
181
182    async navigateToFolder(folderId, folderName) {
183        _currentFolderId = folderId;
184
185        if (folderId) {
186            _breadcrumbs.push({ id: folderId, name: folderName });
187        }
188
189        this._renderBreadcrumbs();
190        await this._loadFolder(folderId);
191    },
192
193    // ─── Internal ────────────────────────────────────────
194
195    _bindEvents() {
196        _eventsAbort = new AbortController();
197        const signal = _eventsAbort.signal;
198
199        // Close button
200        document.getElementById('onedrive-close-btn')?.addEventListener('click', () => this.close());
201
202        // Backdrop click
203        document.getElementById('onedrive-backdrop')?.addEventListener('click', () => this.close());
204
205        // Escape key
206        document.addEventListener('keydown', (e) => {
207            if (e.key === 'Escape' && _open) {
208                // A modal on top of the panel owns Escape — otherwise dismissing the
209                // project picker or an RFI form would also tear the panel down and
210                // lose the user's place in their folder tree.
211                if (document.getElementById('app-modals')?.childElementCount) return;
212                const importModal = document.getElementById('onedrive-import-modal');
213                if (importModal && !importModal.classList.contains('hidden')) return;
214
215                // Close menus first, then panel
216                if (this._isMenuOpen()) {
217                    this._closeSortMenu();
218                    this._closeViewMenu();
219                    this._closeItemMenu();
220                } else {
221                    this.close();
222                }
223            }
224        }, { signal });
225
226        // Connect button
227        document.getElementById('onedrive-connect-btn')?.addEventListener('click', async () => {
228            await safeAsync(async () => {
229                const user = await msGraph.signIn();
230                if (user) {
231                    window.showToast?.('Microsoft 365 Connected', 'success', { description: user.userPrincipalName || user.mail });
232                    this.open(); // Re-open to load files
233                } else {
234                    window.showToast?.('Sign-in unavailable', 'error', { description: 'Microsoft 365 is not configured for this tenant' });
235                }
236            }, { label: 'OneDrive', toast: 'Connection Failed' });
237        });
238
239        // Search (debounced)
240        document.getElementById('onedrive-search')?.addEventListener('input', (e) => {
241            clearTimeout(_searchTimeout);
242            const query = e.target.value.trim();
243
244            if (!query) {
245                _searchTimeout = setTimeout(() => this._loadFolder(_currentFolderId), 300);
246                return;
247            }
248
249            _searchTimeout = setTimeout(async () => {
250                await this._searchFiles(query);
251            }, 400);
252        });
253
254        // File list clicks (delegated)
255        document.getElementById('onedrive-file-list')?.addEventListener('click', (e) => {
256            // Action buttons first
257            const actionBtn = e.target.closest('[data-action]');
258            if (actionBtn) {
259                // stopPropagation also keeps this click from reaching the document
260                // listener that _showItemMenu installs for click-outside.
261                e.stopPropagation();
262                const action = actionBtn.dataset.action;
263                const itemId = actionBtn.dataset.itemId;
264
265                if (action === 'menu') {
266                    // Toggle: a second click on the same chevron closes the menu.
267                    if (_itemMenuAnchor === actionBtn) {
268                        this._closeItemMenu();
269                        return;
270                    }
271                    const item = _currentItems.find(i => i.id === itemId);
272                    if (item) this._showItemMenu(item, actionBtn);
273                }
274                return;
275            }
276
277            // Folder navigation
278            const row = e.target.closest('.onedrive-item');
279            if (!row) return;
280
281            const isFolder = row.dataset.isFolder === 'true';
282            if (isFolder) {
283                this.navigateToFolder(row.dataset.itemId, row.dataset.itemName);
284            }
285        });
286
287        // Breadcrumb clicks
288        document.getElementById('onedrive-breadcrumbs')?.addEventListener('click', (e) => {
289            const crumb = e.target.closest('.onedrive-crumb');
290            if (!crumb) return;
291
292            const folderId = crumb.dataset.folderId;
293            if (folderId === '') {
294                this.navigateToRoot();
295            } else {
296                const idx = _breadcrumbs.findIndex(b => b.id === folderId);
297                if (idx >= 0) _breadcrumbs = _breadcrumbs.slice(0, idx + 1);
298                _currentFolderId = folderId;
299                this._renderBreadcrumbs();
300                this._loadFolder(folderId);
301            }
302        });
303
304        // Sort button toggle
305        document.getElementById('onedrive-sort-btn')?.addEventListener('click', (e) => {
306            e.stopPropagation();
307            this._closeViewMenu();
308            this._toggleSortMenu();
309        });
310
311        // Sort menu item clicks
312        document.getElementById('onedrive-sort-menu')?.addEventListener('click', (e) => {
313            const sortOpt = e.target.closest('.onedrive-sort-option');
314            if (sortOpt) {
315                _sortState.column = sortOpt.dataset.sortCol;
316                this._persistSort();
317                this._updateSortMenuState();
318                this._updateColumnHeaders();
319                this._reRender();
320                this._closeSortMenu();
321                return;
322            }
323
324            const dirOpt = e.target.closest('.onedrive-dir-option');
325            if (dirOpt) {
326                _sortState.direction = dirOpt.dataset.sortDir;
327                this._persistSort();
328                this._updateSortMenuState();
329                this._updateColumnHeaders();
330                this._reRender();
331                this._closeSortMenu();
332            }
333        });
334
335        // View button toggle
336        document.getElementById('onedrive-view-btn')?.addEventListener('click', (e) => {
337            e.stopPropagation();
338            this._closeSortMenu();
339            this._toggleViewMenu();
340        });
341
342        // View menu item clicks
343        document.getElementById('onedrive-view-menu')?.addEventListener('click', (e) => {
344            const opt = e.target.closest('.onedrive-view-option');
345            if (!opt) return;
346
347            _viewMode = opt.dataset.view;
348            localStorage.setItem('od_viewMode', _viewMode);
349            this._updateViewMenuState();
350            this._updateViewBtnIcon();
351            this._updateColumnHeadersVisibility();
352            this._reRender();
353            this._closeViewMenu();
354        });
355
356        // Column header clicks for sort
357        document.getElementById('onedrive-column-headers')?.addEventListener('click', (e) => {
358            const header = e.target.closest('.onedrive-col-header');
359            if (!header) return;
360
361            const col = header.dataset.sortCol;
362            if (_sortState.column === col) {
363                _sortState.direction = _sortState.direction === 'asc' ? 'desc' : 'asc';
364            } else {
365                _sortState.column = col;
366                _sortState.direction = 'asc';
367            }
368
369            this._persistSort();
370            this._updateSortMenuState();
371            this._updateColumnHeaders();
372            this._reRender();
373        });
374
375        // Click outside to close menus
376        document.addEventListener('click', (e) => {
377            if (!e.target.closest('#onedrive-sort-wrapper')) this._closeSortMenu();
378            if (!e.target.closest('#onedrive-view-wrapper')) this._closeViewMenu();
379        }, { signal });
380    },
381
382    /**
383     * Tear down everything init() and _bindEvents() attached.
384     * Called from router.js teardownAuthenticatedServices per the cleanup contract, beside
385     * TeamsPanel.destroy(). Without it the panel's DOM, its two document listeners and its
386     * cached `_currentItems` all cross the session boundary — and open() skips the refetch
387     * while `_currentItems` is populated, so the next user would see the previous user's
388     * OneDrive listing.
389     */
390    destroy() {
391        _createUiAbort?.abort();
392        _createUiAbort = null;
393        _eventsAbort?.abort();
394        _eventsAbort = null;
395
396        this._closeSortMenu();
397        this._closeViewMenu();
398        this._closeItemMenu();
399
400        if (_open) this.close();
401
402        // init() reparents these into <body>;
402 without removing them a re-login would run
403        // init() again and append duplicates of all three ids.
404        document.getElementById('onedrive-panel')?.remove();
405        document.getElementById('onedrive-backdrop')?.remove();
406        document.getElementById('onedrive-import-modal')?.remove();
407
408        clearTimeout(_searchTimeout);
409        _searchTimeout = null;
410
411        // Required, not cosmetic: open() (:93) re-runs init() only when NOT initialised, so
412        // leaving this true would leave the panel silently dead for the tab's life. This is
413        // exactly what TeamsPanel.destroy() does.
414        _initialized = false;
415        _open = false;
416        _currentFolderId = null;
417        _breadcrumbs = [];
418        _currentItems = [];
419    },
420
421    // ─── Sort / View Menu Helpers ────────────────────────
422
423    _toggleSortMenu() {
424        const menu = document.getElementById('onedrive-sort-menu');
425        if (!menu) return;
426        menu.classList.toggle('hidden');
427        if (!menu.classList.contains('hidden')) {
428            lucide.createIcons({ root: menu });
429        }
430    },
431
432    _closeSortMenu() {
433        document.getElementById('onedrive-sort-menu')?.classList.add('hidden');
434    },
435
436    _toggleViewMenu() {
437        const menu = document.getElementById('onedrive-view-menu');
438        if (!menu) return;
439        menu.classList.toggle('hidden');
440        if (!menu.classList.contains('hidden')) {
441            lucide.createIcons({ root: menu });
442        }
443    },
444
445    _closeViewMenu() {
446        document.getElementById('onedrive-view-menu')?.classList.add('hidden');
447    },
448
449    _isMenuOpen() {
450        const sort = document.getElementById('onedrive-sort-menu');
451        const view = document.getElementById('onedrive-view-menu');
452        return (sort && !sort.classList.contains('hidden'))
453            || (view && !view.classList.contains('hidden'))
454            || !!_itemMenuEl;
455    },
456
457    _updateSortMenuState() {
458        // Highlight active sort column
459        document.querySelectorAll('.onedrive-sort-option').forEach(el => {
460            if (el.dataset.sortCol === _sortState.column) {
461                el.classList.add('onedrive-menu-active');
462            } else {
463                el.classList.remove('onedrive-menu-active');
464            }
465        });
466
467        // Highlight active direction
468        document.querySelectorAll('.onedrive-dir-option').forEach(el => {
469            if (el.dataset.sortDir === _sortState.direction) {
470                el.classList.add('onedrive-menu-active');
471            } else {
472                el.classList.remove('onedrive-menu-active');
473            }
474        });
475    },
476
477    _updateViewMenuState() {
478        document.querySelectorAll('.onedrive-view-option').forEach(el => {
479            const check = el.querySelector('.onedrive-view-check');
480            if (el.dataset.view === _viewMode) {
481                check?.classList.remove('hidden');
482            } else {
483                check?.classList.add('hidden');
484            }
485        });
486        this._updateViewBtnIcon();
487    },
488
489    _updateViewBtnIcon() {
490        const icon = document.getElementById('onedrive-view-icon');
491        if (!icon) return;
492
493        const iconMap = { list: 'list', compact: 'align-justify', tiles: 'layout-grid' };
494        icon.dataset.lucide = iconMap[_viewMode] || 'list';
495        lucide.createIcons({ root: icon.parentElement });
496    },
497
498    _updateColumnHeaders() {
499        const container = document.getElementById('onedrive-column-headers');
500        if (!container) return;
501
502        container.querySelectorAll('.onedrive-col-header').forEach(el => {
503            const col = el.dataset.sortCol;
504            const iconEl = el.querySelector('[data-lucide]');
505            const isActive = col === _sortState.column;
506
507            if (isActive) {
508                el.classList.add('text-theme-primary', 'font-semibold');
509                el.classList.remove('text-theme-secondary');
510                if (iconEl) {
511                    iconEl.dataset.lucide = _sortState.direction === 'asc' ? 'chevron-up' : 'chevron-down';
512                    iconEl.classList.remove('hidden');
513                }
514            } else {
515                el.classList.remove('text-theme-primary', 'font-semibold');
516                el.classList.add('text-theme-secondary');
517                if (iconEl) {
518                    iconEl.classList.add('hidden');
519                }
520            }
521        });
522        lucide.createIcons({ root: container });
523    },
524
525    _updateColumnHeadersVisibility() {
526        const headers = document.getElementById('onedrive-column-headers');
527        if (!headers) return;
528
529        if (_viewMode === 'tiles') {
530            headers.classList.add('hidden');
531        } else {
532            headers.classList.remove('hidden');
533        }
534    },
535
536    _persistSort() {
537        localStorage.setItem('od_sort', JSON.stringify(_sortState));
538    },
539
540    // ─── Data Loading ────────────────────────────────────
541
542    async _loadFolder(folderId) {
543        const container = document.getElementById('onedrive-file-list');
544        if (!container) return;
545        const generation = ++_folderLoadGeneration;
546
547        container.innerHTML = `
548            <div class="flex items-center justify-center py-8">
549                <div class="w-4 h-4 border-2 border-st-primary border-t-transparent roun
549ded-full animate-spin"></div>
550                <span class="text-xs text-theme-secondary ml-2">Loading...</span>
551            </div>`;
552
553        try {
554            let items;
555            if (folderId) {
556                const result = await oneDrive.getFolder(folderId);
557                items = result.items;
558            } else {
559                items = await oneDrive.getRoot();
560            }
561            if (generation !== _folderLoadGeneration || !container.isConnected) return;
562
563            _currentItems = items;
564            const sorted = this._sortItems(items);
565            this._renderItems(container, sorted);
566        } catch (err) {
567            if (generation !== _folderLoadGeneration || !container.isConnected) return;
568            console.error('[OneDrive] Load failed:', err);
569            container.innerHTML = `
570                <div class="text-center py-8 px-4">
571                    <i data-lucide="alert-circle" class="w-6 h-6 text-sb-error mx-auto mb-2"></i>
572                    <p class="text-sm text-theme-secondary">Failed to load files</p>
573                    <p class="text-xs text-theme-secondary mt-1">${escapeHtml(err.message || '')}</p>
574                </div>`;
575            lucide.createIcons({ root: container });
576        }
577    },
578
579    async _searchFiles(query) {
580        const container = document.getElementById('onedrive-file-list');
581        if (!container) return;
582
583        container.innerHTML = `
584            <div class="flex items-center justify-center py-8">
585                <div class="w-4 h-4 border-2 border-st-primary border-t-transparent rounded-full animate-spin"></div>
586                <span class="text-xs text-theme-secondary ml-2">Searching...</span>
587            </div>`;
588
589        try {
590            const items = await oneDrive.search(query);
591            _currentItems = items;
592            const sorted = this._sortItems(items);
593            this._renderItems(container, sorted);
594        } catch (err) {
595            container.innerHTML = `
596                <div class="text-center py-8">
597                    <p class="text-sm text-theme-secondary">Search failed</p>
598                </div>`;
599        }
600    },
601
602    // ─── Sorting ─────────────────────────────────────────
603
604    _sortItems(items) {
605        const { column, direction } = _sortState;
606        const dir = direction === 'asc' ? 1 : -1;
607
608        return [...items].sort((a, b) => {
609            // Folders always first
610            if (a.isFolder && !b.isFolder) return -1;
611            if (!a.isFolder && b.isFolder) return 1;
612
613            switch (column) {
614                case 'type': {
615                    const extA = a.name.split('.').pop()?.toLowerCase() || '';
616                    const extB = b.name.split('.').pop()?.toLowerCase() || '';
617                    return dir * extA.localeCompare(extB);
618                }
619                case 'name':
620                    return dir * a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
621                case 'modified': {
622                    const da = new Date(a.lastModified || 0).getTime();
623                    const db = new Date(b.lastModified || 0).getTime();
624                    return dir * (da - db);
625                }
626                case 'size':
627                    return dir * ((a.size || 0) - (b.size || 0));
628                default:
629                    return 0;
630            }
631        });
632    },
633
634    _reRender() {
635        const container = document.getElementById('onedrive-file-list');
636        if (!container || !_currentItems.length) return;
637        const sorted = this._sortItems(_currentItems);
638        this._renderItems(container, sorted);
639    },
640
641    // ─── Rendering ───────────────────────────────────────
642
643    _renderItems(container, items) {
644        // Any re-render replaces the rows, orphaning the menu's anchor — covers
645        // folder navigation, search, sort and view-mode changes in one place.
646        this._closeItemMenu();
647
648        if (!items.length) {
649            container.innerHTML = `
650                <div class="text-center py-8">
651                    <i data-lucide="folder-open" class="w-6 h-6 text-theme-secondary mx-auto mb-2"></i>
652                    <p class="text-sm text-theme-secondary">This folder is empty</p>
653                </div>`;
654            lucide.createIcons({ root: container });
655            return;
656        }
657
658        if (_viewMode === 'tiles') {
659            this._renderTiles(container, items);
660        } else {
661            this._renderList(container, items, _viewMode === 'compact');
662        }
663    },
664
665    _renderList(container, items, compact) {
666        const py = compact ? 'py-1.5' : 'py-2.5';
667        const iconSize = compact ? 'w-4 h-4' : 'w-5 h-5';
668        const textSize = compact ? 'text-xs' : 'text-sm';
669        const subTextSize = compact ? 'text-2xs' : 'text-2xs';
670
671        let html = '';
672        for (const item of items) {
673            const { icon, color } = getMicrosoftFileColor(item.name, item.isFolder);
674            const modified = formatModifiedDate(item.lastModified);
675            const sizeStr = item.isFolder
676                ? `${item.childCount} item${item.childCount !== 1 ? 's' : ''}`
677                : formatSize(item.size);
678
679            html += `
680                <div class="onedrive-item group flex items-center gap-2 px-4 ${py} cursor-pointer hover:bg-surface-2/50 transition-colors border-b border-theme-30"
681                     data-item-id="${item.id}" data-is-folder="${item.isFolder}" data-item-name="${escapeAttr(item.name)}">
682                    <div class="w-7 shrink-0 flex justify-center">
683                        <i data-lucide="${icon}" class="${iconSize} ${color} shrink-0"></i>
684                    </div>
685                    <div class="flex-1 min-w-0">
686                        <p class="${textSize} text-theme-primary truncate">${escapeHtml(item.name)}</p>
687                    </div>
688                    <div class="w-28 shrink-0 ${subTextSize} text-theme-secondary truncate hidden sm:block">${modified}</div>
689                    <div class="w-20 shrink-0 ${subTextSize} text-theme-secondary text-right hidden sm:block">${sizeStr}</div>
690                    <div class="w-10 shrink-0 flex items-center justify-end">
691                        ${!item.isFolder ? `
692                            <button class="od-item-menu-btn p-1 rounded hover:bg-theme-secondary transition-colors shrink-0 flex items-center justify-center"
693                                    data-action="menu" data-item-id="${item.id}"
694                                    aria-haspopup="menu" aria-expanded="false" title="Options">
695                                <i data-lucide="chevron-down" class="w-3.5 h-3.5 text-theme-secondary"></i>
696                            </button>
697                        ` : `
698                            <i data-lucide="chevron-right" class="w-4 h-4 text-theme-secondary shrink-0"></i>
699                        `}
700                    </div>
701                </div>`;
702        }
703
704        container.innerHTML = html;
705        lucide.createIcons({ root: container });
706    },
707
708    _renderTiles(container, items) {
709        let html = '<div class="grid grid-cols-3 gap-2 p-4">';
710
711        for (const item of items) {
712            const { icon, color } = getMicrosoftFileColor(item.name, item.isFolder);
713
714            html += `
715                <div class="onedrive-item flex flex-col items-center py-3 px-2 rounded-lg cursor-pointer transition-colors hover:bg-theme-secondary relative"
716                     data-item-id="${item.id}" data-is-folder="${item.isFolder}" data-item-name="${escapeAttr(item.name)}">
717                    <i data-lucide="${icon}" class="w-10 h-10 ${color} mb-2"></i>
718                    <p class="text-xs text-theme-primary text-center w-full truncate" title="${escapeAttr(item.name)}">${escapeHtml(item.name)}</p>
719                    ${item.isFolder
720                        ? `<p class="text-2xs text-theme-secondary">${item.childCount} items</p>`
721                        : `<button class="od-item-menu-btn absolute top-1 right-1 p-1 rounded hover:bg-theme-secondary transition-colors"
722                                   data-action="menu" data-item-id="${item.id}"
723                                   aria-haspopup="menu" aria-expanded="false" title="Options">
724                               <i data-lucide="chevron-down" class="w-3.5 h-3.5 text-theme-secondary"></i>
725                           </button>`}
726                </div>`;
727        }
728
729        html += '</div>';
730        container.innerHTML = html;
731        lucide.createIcons({ root: container });
732    },
733
734    _renderBreadcrumbs() {
735        const container = document.getElementById('onedrive-breadcrumbs');
736        if (!container) return;
737
738        let html = `
739            <div class="flex items-center gap-1 text-xs whitespace-nowrap">
740                <button class="onedrive-crumb text-st-primary font-semibold hover:underline flex items-center gap-1" data-folder-id="">
741                    ${OD_CLOUD_SVG_SM}
742                    My files
743                </button>`;
744
745        for (const crumb of _breadcrumbs) {
746            html += `
747                <i data-lucide="chevron-right" class="w-3 h-3 text-theme-secondary"></i>
748                <button class="onedrive-crumb btn-st-text-link truncate max-w-24" data-folder-id="${crumb.id || ''}" title="${escapeAttr(crumb.name)}
748">
749                    ${escapeHtml(crumb.name)}
750                </button>`;
751        }
752
753        html += '</div>';
754        container.innerHTML = html;
755        lucide.createIcons({ root: container });
756    },
757
758    // ─── Per-file Action Menu ────────────────────────────
759
760    /**
761     * Open the shared per-file action menu for a OneDrive item.
762     *
763     * The menu is portalled to <body> rather than rendered inside the panel, for
764     * two reasons that both break it otherwise:
765     *   1. #onedrive-panel carries a `transform` (the slide-in), which makes it a
766     *      containing block for position:fixed descendants — a fixed menu inside
767     *      would position against the panel, not the viewport.
768     *   2. The panel is z-fixed (1035). z-dropdown (1000) would render behind it,
769     *      so the menu sits at z-popover (1060).
770     * It also lives outside #onedrive-file-list, which scrolls and would clip it.
771     */
772    async _showItemMenu(item, anchorEl) {
773        this._closeItemMenu();
774
775        if (!_menuTemplatesLoaded) {
776            await loadTemplates('components/message-sub-templates');
777            _menuTemplatesLoaded = true;
778        }
779
780        const menu = document.createElement('div');
781        menu.className = 'fixed w-52 bg-theme-primary border border-theme rounded-lg shadow-sb-lg py-1 z-popover animate-in fade-in zoom-in duration-150';
782        menu.setAttribute('role', 'menu');
783        menu.innerHTML = await renderToString('#tpl-external-attach-menu', { createLabel: 'Create from file' });
784
785        // Measure hidden so the flip decision uses the real rendered height
786        menu.style.visibility = 'hidden';
787        document.body.appendChild(menu);
788        afterRender(menu);
789
790        const rect = anchorEl.getBoundingClientRect();
791        const menuH = menu.offsetHeight;
792        const flipUp = rect.bottom + menuH + 8 > window.innerHeight;
793        menu.style.top = flipUp
794            ? `${Math.max(8, rect.top - menuH - 4)}px`
795            : `${rect.bottom + 4}px`;
796        menu.style.left = `${Math.max(8, Math.min(rect.right - ITEM_MENU_WIDTH, window.innerWidth - ITEM_MENU_WIDTH - 8))}px`;
797        menu.style.visibility = '';
798
799        menu.addEventListener('click', async (e) => {
800            const btn = e.target.closest('.ext-attach-action');
801            if (!btn) return;
802            const action = btn.dataset.action;
803            this._closeItemMenu();
804
805            if (action === 'folder') {
806                await this._handleImport(item.id, item.name);
807            } else if (action === 'download') {
808                await safeAsync(() => oneDrive.download(item.id), {
809                    label: 'OneDrive', toast: 'Download Failed',
810                });
811            } else if (action?.startsWith('create-')) {
812                await this._handleCreateFromFile(item, action.slice(7));
813            }
814        });
815
816        // Dismissal — a fixed menu can't follow a scrolling or resizing anchor
817        const onDocClick = (e) => {
818            if (!menu.contains(e.target) && !anchorEl.contains(e.target)) this._closeItemMenu();
819        };
820        const onReflow = () => this._closeItemMenu();
821        const list = document.getElementById('onedrive-file-list');
822
823        document.addEventListener('click', onDocClick);
824        list?.addEventListener('scroll', onReflow, { passive: true });
825        window.addEventListener('resize', onReflow);
826
827        _itemMenuEl = menu;
828        _itemMenuAnchor = anchorEl;
829        _itemMenuCleanup = () => {
830            document.removeEventListener('click', onDocClick);
831            list?.removeEventListener('scroll', onReflow);
832            window.removeEventListener('resize', onReflow);
833        };
834        anchorEl.setAttribute('aria-expanded', 'true');
835    },
836
837    _closeItemMenu() {
838        if (_itemMenuCleanup) {
839            _itemMenuCleanup();
840            _itemMenuCleanup = null;
841        }
842        _itemMenuEl?.remove();
843        _itemMenuEl = null;
844        _itemMenuAnchor?.setAttribute('aria-expanded', 'false');
845        _itemMenuAnchor = null;
846    },
847
848    // ─── Create RFI / CHG / SI / Submittal from a OneDrive file ──
849
850    /**
851     * Open the same create modal the message center uses, with the OneDrive file
852     * shown as a pre-loaded attachment, then attach it to the new record.
853     *
854     * The file bytes never touch the browser. message-create-link's
855     * uploadMessageAttachment() early-returns when `attachment.url` is absent, so
856     * omitting it renders the read-only file chip but skips the blob upload — the
857     * onCreated hook then has the API pull the file server-side instead.
858     */
859    async _handleCreateFromFile(item, type) {
860        if (!CREATE_LABELS[type]) return;
861
862        const panel = document.getElementById('onedrive-panel');
863        const uiSignal = type === 'submittal' ? _createUiAbort?.signal : null;
864        const settlementOwner = type === 'submittal' ? captureDurableSettlementOwner() : null;
865        const canContinueDurable = () => canContinueDurableSettlement(settlementOwner);
866        const canPublish = () => !!uiSignal && !uiSignal.aborted && _open
867            && panel?.isConnected && document.getElementById('onedrive-panel') === panel
868            && canContinueDurable();
869        if (type === 'submittal' && !canPublish()) return;
870
871        const projectId = await this._pickProject(uiSignal);
872        if (!projectId) return;
873        if (type === 'submittal' && !canPublish()) return;
874
875        const label = CREATE_LABELS[type];
876
877        const opts = {
878            attachment: {
879                fileName: item.name,
880                contentType: item.mimeType || 'application/octet-stream',
881            },
882            prefill: { title: stripExtension(item.name) },
883            onCreated: async (itemId) => {
884                if (type === 'submittal' && !canContinueDurable()) return false;
885                // The pre-auth download URL is short-lived and the user may have
886                // sat in this modal for a while — resolve it now, not on click.
887                const { url } = await oneDrive.getDownloadUrl(item.id);
888                if (type === 'submittal' && !canContinueDurable()) return false;
889                if (!url) throw new Error('Could not get download URL');
890
891                const importFn = type === 'submittal' ? importToSubmittal : importToTask;
892                await importFn(url, item.name, itemId, {
893                    projectId,
894                    description: 'Uploaded from OneDrive',
895                });
896                if (type === 'submittal' && !canContinueDurable()) return false;
897            },
898        };
899
900        if (type === 'submittal') Object.assign(opts, {
901            signal: uiSignal, canPublish, canContinueDurable,
902        });
903
904        // Submittal owns one aggregate persisted-but-follow-up-failed outcome in
905        // message-create-link. Other record types retain their existing local settlement.
906        if (type !== 'submittal') {
907            const onCreated = opts.onCreated;
908            opts.onCreated = async (itemId, meta) => {
909                try {
910                    await onCreated(itemId, meta);
911                } catch (err) {
912                    console.error('[OneDrive] Attach to new record failed:', err);
913                    window.showToast?.(`${label} created, file not attached`, 'error', {
914                        description: `Attach "${item.name}" manually from the ${label}'s documents.`,
915                    });
916                }
917            };
918        }
919
920        if (type === 'submittal') {
921            try {
922                const clm = await import('../pages/message-create-link.js');
923                if (canPublish()) await clm.showCreateSubmittalAndLink(projectId, null, opts);
924            } catch (error) {
925                if (canPublish()) window.showToast?.(`Failed to open ${label} form`, 'error', { description: error.message });
926            }
927            return;
928        }
929
930        await safeAsync(async () => {
931            const clm = await import('../pages/message-create-link.js');
932            if (type === 'rfi') await clm.showCreateRfiAndLink(projectId, null, opts);
933            else if (type === 'chg') await clm.showCreateChgAndLink(projectId, null, opts);
934            else if (type === 'si') await clm.showCreateSiAndLink(projectId, null, opts);
935        }, { label: 'OneDrive', toast: `Failed to open ${label} form` });
936    },
937
938    /**
939     * Ask which project the new record belongs to.
940     * Resolves to a projectId, or null if the user dismissed the picker.
941     */
942    async _pickProject(signal) {
943        if (!_menuTemplatesLoaded) {
944            await loadTemplates('components/message-sub-templates');
945            _menuTemplatesLoaded = true;
946        }
947
948        return new Promise((resolve) => {
949            let settled = false;
950            const finish = (val) => {
951                if (settled) return;
952                settled = true;
953                resolve(val);
954            };
955
956            showModal('#tpl-outlook-project-picker-modal', {}, {
957                signal,
958                // Covers every exit that isn't Continue (dismiss / Escape / backdrop)
959                onClose: () => finish(null),
960            }).then(async (modal) => {
961                if (signal?.aborted) return finish(null);
962                const select = modal.element.querySelector('#ol-picker-project');
963                await this._loadProjectOptions(select, signal);
964                if (signal?.aborted || !modal.element.isConnected) return finish(null);
965                SearchableDropdown.enhanceAll(modal.element);
966
967                modal.element.querySelector('#btn-ol-picker-confirm')?.addEventListener('click', () => {
968                    const projectId = parseInt(select?.value, 10);
969                    if (!projectId) {
970                        flagFieldError(modal.element, select, 'Project is required', { clearOn: 'change' });
971                        return;
972                    }
973                    finish(projectId);
974                    modal.close();
975                });
976            }).catch((err) => {
977                console.error('[OneDrive] Project picker failed:', err);
978                finish(null);
979            });
980        });
981    },
982
983    // ─── Actions ─────────────────────────────────────────
984
985    async _handleImport(itemId, itemName) {
986        await safeAsync(async () => {
987            // Get the pre-authenticated download URL from OneDrive
988            const { url } = await oneDrive.getDownloadUrl(itemId);
989            if (!url) throw new Error('Could not get download URL');
990
991            // Show the import modal
992            await this._showImportModal(url, itemName);
993        }, { label: 'OneDrive', toast: 'Import Failed' });
994    },
995
996    // ─── Import Modal (matches message center folder picker) ─────
997
998    async _showImportModal(downloadUrl, fileName) {
999        const modal = document.getElementById('onedrive-import-modal');
1000        if (!modal) return;
1001
1002        // Store import state
1003        this._importState = { downloadUrl, fileName, projectId: null, currentFolderId: 0 };
1004
1005        // Set filename display
1006        const fnEl = document.getElementById('onedrive-import-filename');
1007        if (fnEl) fnEl.textContent = fileName;
1008
1009        // Reset UI
1010        const projectSelect = document.getElementById('onedrive-import-project');
1011        const toolbar = document.getElementById('od-fp-toolbar');
1012        const itemsEl = document.getElementById('od-fp-items');
1013        const confirmBtn = document.getElementById('onedrive-import-confirm');
1014
1015        if (projectSelect) projectSelect.value = '';
1016        if (toolbar) toolbar.classList.add('hidden');
1017        if (itemsEl) itemsEl.innerHTML = `
1018            <div class="flex flex-col items-center justify-center py-10 text-center">
1019                <div class="w-12 h-12 rounded-full bg-surface-2/20 flex items-center justify-center mb-3">
1020                    <i data-lucide="folder-open" class="w-6 h-6 text-theme-secondary"></i>
1021                </div>
1022                <p class="text-sm text-theme-secondary">Select a project to browse folders</p>
1023            </div>`;
1024        if (confirmBtn) confirmBtn.disabled = true;
1025
1026        // Load projects, then enhance searchable dropdown
1027        await this._loadImportProjects();
1028        SearchableDropdown.enhanceAll(modal);
1029
1030        // Show modal
1031        modal.classList.remove('hidden');
1032        lucide.createIcons({ root: modal });
1033
1034        // Bind events
1035        this._bindImportModalEvents();
1036    },
1037
1038    _closeImportModal() {
1039        const modal = document.getElementById('onedrive-import-modal');
1040        if (modal) modal.classList.add('hidden');
1041
1042        this._importState = null;
1043
1044        if (this._importAbort) {
1045            this._importAbort.abort();
1046            this._importAbort = null;
1047        }
1048    },
1049
1050    _bindImportModalEvents() {
1051        if (this._importAbort) this._importAbort.abort();
1052        this._importAbort = new AbortController();
1053        const opts = { signal: this._importAbort.signal };
1054
1055        // Close buttons
1056        document.getElementById('onedrive-import-close')?.addEventListener('click', () => this._closeImportModal(), opts);
1057        document.getElementById('onedrive-import-cancel')?.addEventListener('click', () => this._closeImportModal(), opts);
1058
1059        // Click backdrop (the modal-backdrop-st itself)
1060        document.getElementById('onedrive-import-modal')?.addEventListener('click', (e) => {
1061            if (e.target === e.currentTarget) this._closeImportModal();
1062        }, opts);
1063
1064        // Project selection
1065        document.getElementById('onedrive-import-project')?.addEventListener('change', (e) => {
1066            const projectId = parseInt(e.target.value);
1067            const toolbar = document.getElementById('od-fp-toolbar');
1068            const confirmBtn = document.getElementById('onedrive-import-confirm');
1069
1070            if (projectId > 0) {
1071                this._importState.projectId = projectId;
1072                this._importState.currentFolderId = 0;
1073                if (toolbar) toolbar.classList.remove('hidden');
1074                if (confirmBtn) confirmBtn.disabled = false;
1075                this._loadImportFolder(0);
1076            } else {
1077                this._importState.projectId = null;
1078                if (toolbar) toolbar.classList.add('hidden');
1079                if (confirmBtn) confirmBtn.disabled = true;
1080                const itemsEl = document.getElementById('od-fp-items');
1081                if (itemsEl) itemsEl.innerHTML = `
1082                    <div class="flex flex-col items-center justify-center py-10 text-center">
1083                        <div class="w-12 h-12 rounded-full bg-surface-2/20 flex items-center justify-center mb-3">
1084                            <i data-lucide="folder-open" class="w-6 h-6 text-theme-secondary"></i>
1085                        </div>
1086                        <p class="text-sm text-theme-secondary">Select a project to browse folders</p>
1087                    </div>`;
1088                lucide.createIcons({ root: itemsEl });
1089            }
1090        }, opts);
1091
1092        // Folder click-through (delegated)
1093        document.getElementById('od-fp-items')?.addEventListener('click', (e) => {
1094            const folder = e.target.closest('[data-folder-id]');
1095            if (folder) this._loadImportFolder(parseInt(folder.dataset.folderId));
1096        }, opts);
1097
1098        // Breadcrumb navigation
1099        document.getElementById('od-fp-breadcrumbs')?.addEventListener('click', (e) => {
1100            const btn = e.target.closest('.fp-breadcrumb-btn');
1101            if (btn) this._loadImportFolder(parseInt(btn.dataset.folderId) || 0);
1102        }, opts);
1103
1104        // New Folder toggle
1105        const newFolderRow = document.getElementById('od-fp-new-folder-row');
1106        const newFolderInput = document.getElementById('od-fp-new-folder-input');
1107
1108        document.getElementById('od-fp-new-folder')?.addEventListener('click', () => {
1109            newFolderRow?.classList.remove('hidden');
1110            newFolderInput.value = '';
1111            newFolderInput?.focus();
1112        }, opts);
1113
1114        document.getElementById('od-fp-cancel-folder')?.addEventListener('click', () => {
1115            newFolderRow?.classList.add('hidden');
1116        }, opts);
1117
1118        // Create folder
1119        const createFolder = async () => {
1120            const name = newFolderInput?.value.trim();
1121            if (!name) {
1122                newFolderInput?.classList.add('border-sb-error');
1123                return;
1124            }
1125            newFolderInput?.classList.remove('border-sb-error');
1126            const confirmFolderBtn = document.getElementById('od-fp-confirm-folder');
1127            await runWithBusyButton(confirmFolderBtn, 'Creating...', async () => {
1128                await documentApi.createFolder(this._importState.projectId, {
1129                    name,
1130                    parentFolderId: this._importState.currentFolderId
1131                });
1132                newFolderRow?.classList.add('hidden');
1133                await this._loadImportFolder(this._importState.currentFolderId);
1134            }, { errorTitle: 'Failed to create folder', errorLogPrefix: '[OneDrive] Create folder failed:' });
1135        };
1136
1137        document.getElementById('od-fp-confirm-folder')?.addEventListener('click', createFolder, opts);
1138        newFolderInput?.addEventListener('keydown', (e) => {
1139            if (e.key === 'Enter') createFolder();
1140            if (e.key === 'Escape') newFolderRow?.classList.add('hidden');
1141        }, opts);
1142
1143        // Save Here
1144        document.getElementById('onedrive-import-confirm')?.addEventListener('click', async () => {
1145            await this._executeImport();
1146        }, opts);
1147
1148        // Escape key
1149        document.addEventListener('keydown', (e) => {
1150            if (e.key === 'Escape') this._closeImportModal();
1151        }, opts);
1152    },
1153
1154    async _loadImportProjects() {
1155        await this._loadProjectOptions(document.getElementById('onedrive-import-project'));
1156    },
1157
1158    /** Fill any <select> with the full project list. Shared by the import modal and the project picker. */
1159    async _loadProjectOptions(select, signal) {
1160        if (!select) return;
1161
1162        try {
1163            // Fetch ALL projects (no stage filter) so user can import to any project
1164            const response = await api.get('smrtbuild/projects', signal ? { signal }
1164 : {});
1165            if (signal?.aborted || !select.isConnected) return;
1166            const projects = response?.projects || [];
1167
1168            select.innerHTML = '<option value="">Select a project...</option>';
1169            for (const proj of projects) {
1170                const opt = document.createElement('option');
1171                opt.value = proj.projectId;
1172                opt.textContent = proj.projectNumber
1173                    ? `${proj.projectNumber} — ${proj.projectTitle}`
1174                    : (proj.projectTitle || 'Untitled');
1175                select.appendChild(opt);
1176            }
1177        } catch (err) {
1178            if (err.name === 'AbortError' || signal?.aborted || !select.isConnected) return;
1179            console.error('[OneDrive] Failed to load projects:', err);
1180            select.innerHTML = '<option value="">Failed to load projects</option>';
1181        }
1182    },
1183
1184    async _loadImportFolder(folderId) {
1185        const state = this._importState;
1186        if (!state?.projectId) return;
1187
1188        state.currentFolderId = folderId;
1189        const projectId = state.projectId;
1190        const importSignal = this._importAbort?.signal;
1191
1192        const breadcrumbsEl = document.getElementById('od-fp-breadcrumbs');
1193        const itemsEl = document.getElementById('od-fp-items');
1194        const newFolderRow = document.getElementById('od-fp-new-folder-row');
1195
1196        // Hide new folder row on navigation
1197        newFolderRow?.classList.add('hidden');
1198
1199        // Loading skeleton
1200        if (itemsEl) {
1201            itemsEl.innerHTML = '<div class="flex items-center justify-center py-10"><div class="animate-spin w-6 h-6 border-2 border-st-primary border-t-transparent rounded-full"></div></div>';
1202        }
1203
1204        try {
1205            const data = await documentApi.getFolderContents(projectId, folderId, { signal: importSignal });
1206            if (importSignal?.aborted || this._importState !== state || state.projectId !== projectId || state.currentFolderId !== folderId) return;
1207
1208            // Render breadcrumbs
1209            if (breadcrumbsEl) {
1210                const crumbs = data.breadcrumbs || [];
1211                let bcHtml = `<button class="fp-breadcrumb-btn shrink-0 flex items-center gap-1 text-sm font-medium transition-colors ${crumbs.length ? 'text-st-primary hover:text-st-dark' : 'pointer-events-none text-theme-primary'}" data-folder-id="0">
1212                    <i data-lucide="home" class="w-3.5 h-3.5"></i><span>Documents</span>
1213                </button>`;
1214                for (let i = 0; i < crumbs.length; i++) {
1215                    const isLast = i === crumbs.length - 1;
1216                    bcHtml += `<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-content-2/50 shrink-0"></i>
1217                        <button class="fp-breadcrumb-btn shrink-0 text-sm truncate max-w-32 transition-colors ${isLast ? 'pointer-events-none text-theme-primary font-semibold' : 'text-st-primary hover:text-st-dark font-medium'}" data-folder-id="${crumbs[i].folderId}">
1218                            ${escapeHtml(crumbs[i].name)}
1219                        </button>`;
1220                }
1221                breadcrumbsEl.innerHTML = bcHtml;
1222                lucide.createIcons({ root: breadcrumbsEl });
1223            }
1224
1225            // Sort: folders first, then files, alphabetical
1226            const items = [...(data.items || [])].sort((a, b) => {
1227                if (a.type !== b.type) return a.type === 'F' ? -1 : 1;
1228                return (a.name || '').localeCompare(b.name || '');
1229            });
1230
1231            // Render items grid
1232            if (itemsEl) {
1233                if (!items.length) {
1234                    itemsEl.innerHTML = `
1235                        <div class="flex flex-col items-center justify-center py-10 text-center">
1236                            <div class="w-12 h-12 rounded-full bg-surface-2/20 flex items-center justify-center mb-3">
1237                                <i data-lucide="folder-open" class="w-6 h-6 text-theme-secondary"></i>
1238                            </div>
1239                            <p class="text-sm text-theme-secondary">This folder is empty</p>
1240                        </div>`;
1241                } else {
1242                    let html = '<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">';
1243                    for (const item of items) {
1244                        const isFolder = item.type === 'F';
1245                        const icon = isFolder ? 'folder' : getMicrosoftFileIcon(item.fileName || item.name);
1246                        html += `
1247                            <div class="fp-item relative bg-theme-primary border border-theme rounded-lg p-3 transition-all
1248                                ${isFolder ? 'cursor-pointer hover:border-st-primary/40 hover:shadow-sb-sm' : 'opacity-60'}"
1249                                ${isFolder ? `data-folder-id="${item.id}"` : ''}
1250                                data-type="${item.type}">
1251                                <div class="flex flex-col items-center text-center gap-1.5">
1252                                    <i data-lucide="${icon}" class="w-9 h-9 ${isFolder ? 'text-sb-warning' : 'text-st-primary/50'}"></i>
1253                                    <p class="text-xs font-medium text-theme-primary truncate w-full" title="${escapeAttr(item.name)}">${escapeHtml(item.name)}</p>
1254                                    ${isFolder ? `<p class="text-2xs text-theme-secondary">${item.childCount ?? 0} item${(item.childCount ?? 0) !== 1 ? 's' : ''}</p>` : ''}
1255                                </div>
1256                            </div>`;
1257                    }
1258                    html += '</div>';
1259                    itemsEl.innerHTML = html;
1260                }
1261                lucide.createIcons({ root: itemsEl });
1262            }
1263        } catch (err) {
1264            if (importSignal?.aborted || this._importState !== state || state.projectId !== projectId || state.currentFolderId !== folderId) return;
1265            console.error('[OneDrive] Failed to load folder:', err);
1266            if (itemsEl) {
1267                itemsEl.innerHTML = '<div class="py-10 text-center text-sm text-sb-error">Failed to load folders</div>';
1268            }
1269        }
1270    },
1271
1272    async _executeImport() {
1273        const state = this._importState;
1274        if (!state?.downloadUrl || !state?.fileName || !state?.projectId) {
1275            window.showToast?.('Import Error', 'error', { description: 'Missing required fields' });
1276            return;
1277        }
1278
1279        const saveBtn = document.getElementById('onedrive-import-confirm');
1280        await runWithBusyButton(saveBtn, 'Saving...', async () => {
1281            await importToProjectFolder(
1282                state.downloadUrl, state.fileName, state.currentFolderId, state.projectId,
1283                { description: 'Uploaded from OneDrive' }
1284            );
1285
1286            this._closeImportModal();
1287            window.showToast?.('Saved to project folder', 'success', { description: state.fileName });
1288        }, { errorTitle: 'Import Failed', errorLogPrefix: '[OneDrive] Import failed:' });
1289    },
1290};
1291
1292// ─── Helpers ────────────────────────────────────────────
1293
1294function escapeAttr(str) {
1295    return (str || '').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
1296}
1297
1298/** "Site Plan Rev C.pdf" → "Site Plan Rev C" — used to seed a created record's title. */
1299function stripExtension(name) {
1300    if (!name) return '';
1301    const dot = name.lastIndexOf('.');
1302    return dot > 0 ? name.slice(0, dot) : name;
1303}
1304
1305function formatSize(bytes) {
1306    if (!bytes) return '';
1307    if (bytes < 1024) return bytes + ' B';
1308    if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
1309    if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
1310    return (bytes / 1073741824).toFixed(1) + ' GB';
1311}
1312
1313/**
1314 * Format a date like real OneDrive:
1315 * - Current year: "March 1"
1316 * - Other years: "October 21, 2025"
1317 */
1318function formatModifiedDate(isoStr) {
1319    if (!isoStr) return '';
1320    const d = new Date(isoStr);
1321    if (isNaN(d.getTime())) return '';
1322    const thisYear = new Date().getFullYear() === d.getFullYear();
1323
1324    return d.toLocaleDateString('en-US', {
1325        month: 'long',
1326        day: 'numeric',
1327        ...(thisYear ? {} : { year: 'numeric' }),
1328    });
1329}
1330

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.