diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aa34a0d..c89030a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,19 @@ The following changes are implemented on `main` after `v3.9.4` but are not yet p - Show secure-backup choices only when Backup is opened. - Load the ZIP engine and calculate detailed storage usage only when those features are requested, leaving startup and normal document-opening paths unchanged. +### GitHub Import + +- Removed the 30-file display cap so repository and folder results expose every Markdown file found. +- Added complete subtree traversal when GitHub marks a recursive repository tree response as truncated. +- Added branch, tag, and commit URL resolution, including branch names containing `/`, and pinned each import to the resolved immutable commit SHA. +- Added optional private repository access that accepts fine-grained and classic PATs automatically. +- Kept private access session-only: named tokens remain in memory, disappear on reload or app close, support an optional earlier expiry date, and can be removed individually without a passphrase or unlock step. Retired locally saved GitHub-access records are deleted during upgrade. +- Replaced the verbose token form with a compact add-then-select flow. Add/remove success and failure use application toasts instead of expanding the form with inline status text. +- Restored the compact 520px GitHub URL step while keeping the searchable file picker at 760px. Repository name and ref metadata share one line; explicit tree/blob URLs show the branch or ref, while repository URLs show only the resolved commit. +- Added file search, a compact selected-count badge, borderless toolbar-style controls for select/deselect all and collapse/expand all, and GitHub-style folder and file rows using the application typography scale. +- Changed local, drag-and-drop, desktop, and GitHub Markdown imports to save documents in Explorer without opening every imported file as a tab. +- Batched multi-tab close commands so closing hundreds of tabs performs one state update and render instead of repeatedly rebuilding the interface. + --- ## v3.9.4 diff --git a/README.md b/README.md index 417eb2ef..fef648c0 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Turn a document into a Share Snapshot link for quick handoffs, or start access-c 3D STL Full screen

-- **Import and export:** open local files or public GitHub content; export Markdown, standalone HTML, PNG, Browser Print/Save as PDF, or a legacy raster PDF. +- **Import and export:** open local files or GitHub content from branches, tags, commits, and optionally private repositories; export Markdown, standalone HTML, PNG, Browser Print/Save as PDF, or a legacy raster PDF. - **Optional sharing:** create a Share Snapshot with View only or Can edit access, or start a temporary Live Share room with host, Can edit, and View only capabilities. @@ -110,7 +110,7 @@ Markdown Viewer is local-first, not network-free. The following table shows the | :--- | :--- | | Editing, local imports, Preview, Workspace autosave, and most exports | On the device | | Web libraries and uncached renderer dependencies | CDN requests in the web/PWA build | -| Public GitHub import and emoji lookup | GitHub APIs and raw-content hosts | +| GitHub import and emoji lookup | Public content uses GitHub APIs and raw-content hosts; private content uses an optional PAT only with `api.github.com` | | PlantUML, D2, Graphviz, Vega-Lite, WaveDrom, and some diagram previews | Diagram source can be sent to PlantUML, Kroki, or mermaid.ink | | Consented image, GIF, and video insertion | Public, content-addressed Cloudflare media storage for 90 days | | Large Share Snapshot | Cloudflare KV for 90 days | @@ -156,7 +156,9 @@ Remote renderer services receive the source of the diagram they render. Do not s - Importing a workspace backup permanently replaces the current workspace after confirmation. - Workspace backups do not include trash, desktop history, or crash-recovery journals. - A local Markdown file larger than 10 MB is rejected. -- The public GitHub importer shows at most 30 Markdown files for a repository or folder result. +- The searchable GitHub importer shows every Markdown file found in a repository or folder result and resolves branch, tag, and commit URLs to an immutable commit SHA. +- Private repository access accepts fine-grained and classic PATs automatically. Multiple named tokens can be added, selected, and removed during the current session, with an optional earlier expiry date; tokens are held only in memory and disappear on reload or app close. +- Imported Markdown files are saved to Explorer and remain closed until you choose one, preventing large imports from flooding the tab bar. - Managed source media is limited to 25 MiB before processing; stored payload limits are 300 KiB for still images, 5 MiB for GIFs, and 10 MiB for videos. - Stored Share Snapshot content is limited to 8,000,000 characters and expires after 90 days. - Live Share allows up to 64 WebSocket participants and 8 MB per live message. diff --git a/assets/lucide-icons.css b/assets/lucide-icons.css index 00eaf338..dda69892 100644 --- a/assets/lucide-icons.css +++ b/assets/lucide-icons.css @@ -25,6 +25,9 @@ .lucide { background-color: ButtonText; } } +.lucide-git-branch { --lucide-icon: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48bGluZSB4MT0iNiIgeDI9IjYiIHkxPSIzIiB5Mj0iMTUiLz48Y2lyY2xlIGN4PSIxOCIgY3k9IjYiIHI9IjMiLz48Y2lyY2xlIGN4PSI2IiBjeT0iMTgiIHI9IjMiLz48cGF0aCBkPSJNMTggOWE5IDkgMCAwIDEtOSA5Ii8+PC9zdmc+"); } +.lucide-git-commit-horizontal { --lucide-icon: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIzIi8+PGxpbmUgeDE9IjMiIHgyPSI5IiB5MT0iMTIiIHkyPSIxMiIvPjxsaW5lIHgxPSIxNSIgeDI9IjIxIiB5MT0iMTIiIHkyPSIxMiIvPjwvc3ZnPg=="); } + .lucide-hat-glasses { --lucide-icon: url("data:image/svg+xml;base64,PCEtLSBAbGljZW5zZSBsdWNpZGUtc3RhdGljIHYxLjI1LjAgLSBJU0MgLS0+PHN2ZyBjbGFzcz0ibHVjaWRlIGx1Y2lkZS1oYXQtZ2xhc3NlcyIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xNCAxOGEyIDIgMCAwIDAtNCAwIiAvPjxwYXRoIGQ9Im0xOSAxMS0yLjExLTYuNjU3YTIgMiAwIDAgMC0yLjc1Mi0xLjE0OGwtMS4yNzYuNjFBMiAyIDAgMCAxIDEyIDRIOC41YTIgMiAwIDAgMC0xLjkyNSAxLjQ1Nkw1IDExIiAvPjxwYXRoIGQ9Ik0yIDExaDIwIiAvPjxjaXJjbGUgY3g9IjE3IiBjeT0iMTgiIHI9IjMiIC8+PGNpcmNsZSBjeD0iNyIgY3k9IjE4IiByPSIzIiAvPjwvc3ZnPg=="); } .lucide-power { --lucide-icon: url("data:image/svg+xml;base64,PCEtLSBAbGljZW5zZSBsdWNpZGUtc3RhdGljIHYxLjI1LjAgLSBJU0MgLS0+PHN2ZyBjbGFzcz0ibHVjaWRlIGx1Y2lkZS1wb3dlciIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPjxwYXRoIGQ9Ik0xMiAydjEwIiAvPjxwYXRoIGQ9Ik0xOC40IDYuNmE5IDkgMCAxIDEtMTIuNzcuMDQiIC8+PC9zdmc+"); } diff --git a/desktop-app/resources/index.html b/desktop-app/resources/index.html index 10bfec0b..9ffb674e 100644 --- a/desktop-app/resources/index.html +++ b/desktop-app/resources/index.html @@ -1281,19 +1281,99 @@

Markdown Viewer

diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index daf88105..5651c042 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -534,11 +534,36 @@ document.addEventListener("DOMContentLoaded", async function () { const mobileLiveShareParticipants = document.getElementById("mobile-live-share-participants"); const githubImportModal = document.getElementById("github-import-modal"); const githubImportTitle = document.getElementById("github-import-title"); + const githubImportSubtitle = document.getElementById("github-import-subtitle"); const githubImportUrlInput = document.getElementById("github-import-url"); const githubImportUrlLabel = document.getElementById("github-import-url-label"); + const githubImportAccess = document.getElementById("github-import-access"); + const githubImportAccessToggle = document.getElementById("github-import-access-toggle"); + const githubImportAccessPanel = document.getElementById("github-import-access-panel"); + const githubImportAccessSummary = document.getElementById("github-import-access-summary"); + const githubImportAccessManage = document.getElementById("github-import-access-manage"); + const githubImportAccessForm = document.getElementById("github-import-access-form"); + const githubImportPatSelect = document.getElementById("github-import-pat-select"); + const githubImportAccessSelectedMeta = document.getElementById("github-import-access-selected-meta"); + const githubImportPatAddAnotherBtn = document.getElementById("github-import-pat-add-another"); + const githubImportPatAddCancelBtn = document.getElementById("github-import-pat-add-cancel"); + const githubImportPatNameInput = document.getElementById("github-import-pat-name"); + const githubImportPatExpiryInput = document.getElementById("github-import-pat-expiry"); + const githubImportPatInput = document.getElementById("github-import-pat"); + const githubImportPatSaveBtn = document.getElementById("github-import-pat-save"); + const githubImportPatRemoveBtn = document.getElementById("github-import-pat-remove"); const githubImportFileSelect = document.getElementById("github-import-file-select"); + const githubImportRepositoryContext = document.getElementById("github-import-repository-context"); + const githubImportRepositoryName = document.getElementById("github-import-repository-name"); + const githubImportCommitLink = document.getElementById("github-import-commit-link"); + const githubImportCommit = document.getElementById("github-import-commit"); + const githubImportRefItem = document.getElementById("github-import-ref-item"); + const githubImportRef = document.getElementById("github-import-ref"); + const githubImportBasePath = document.getElementById("github-import-base-path"); const githubImportSelectionToolbar = document.getElementById("github-import-selection-toolbar"); + const githubImportSearchInput = document.getElementById("github-import-search"); const githubImportSelectedCount = document.getElementById("github-import-selected-count"); + const githubImportToggleFoldersBtn = document.getElementById("github-import-toggle-folders"); const githubImportSelectAllBtn = document.getElementById("github-import-select-all"); const githubImportTree = document.getElementById("github-import-tree"); const githubImportError = document.getElementById("github-import-error"); @@ -7746,9 +7771,74 @@ document.addEventListener("DOMContentLoaded", async function () { idsToClose = openTabs.map(function(tab) { return tab.id; }); } closeTabMenus(); - idsToClose.forEach(function(id) { - if (tabs.some(function(tab) { return tab.id === id && isTabOpen(tab); })) closeTab(id); + if (idsToClose.length === 1) { + closeTab(idsToClose[0]); + return; + } + closeTabsInBatch(idsToClose); + } + + function closeTabsInBatch(tabIds) { + const idsToClose = new Set((tabIds || []).filter(Boolean)); + if (!idsToClose.size) return; + + // Live sessions have teardown and restore behavior that must keep using the + // single-tab path. All ordinary documents can be closed in one state update. + if (liveCollaboration && idsToClose.has(liveCollaboration.tabId)) { + Array.from(idsToClose).forEach(function(id) { + if (tabs.some(function(tab) { return tab.id === id && isTabOpen(tab); })) closeTab(id); + }); + return; + } + + const openTabsBeforeClose = getOpenTabs(); + const activeOpenIndex = openTabsBeforeClose.findIndex(function(tab) { return tab.id === activeTabId; }); + const activeWillClose = idsToClose.has(activeTabId); + const splitWillClose = idsToClose.has(secondarySplitTabId) || (activeWillClose && Boolean(secondarySplitTabId)); + const changedIds = []; + const temporaryIds = new Set(); + + if (pendingReviewDelete && idsToClose.has(pendingReviewDelete.tabId)) { + cancelReviewDeleteConfirmation(); + } + if (activeWillClose) saveCurrentTabState(); + saveSecondarySplitState(); + if (splitWillClose) closeDocumentSplitView({ silent: true, renderTabs: false }); + + tabs.forEach(function(tab) { + if (!idsToClose.has(tab.id) || !isTabOpen(tab)) return; + if (isTemporaryDocument(tab)) { + temporaryIds.add(tab.id); + shareSnapshotViewOnlyTabIds.delete(tab.id); + if (tabHistories[tab.id]) delete tabHistories[tab.id]; + return; + } + tab.isOpen = false; + changedIds.push(tab.id); }); + + if (temporaryIds.size) { + tabs = tabs.filter(function(tab) { return !temporaryIds.has(tab.id); }); + } + if (changedIds.length) saveTabsToStorage(tabs, changedIds); + + if (activeWillClose) { + const remainingOpenTabs = getOpenTabs(); + const nextTab = remainingOpenTabs.length + ? remainingOpenTabs[Math.min(Math.max(activeOpenIndex - 1, 0), remainingOpenTabs.length - 1)] + : null; + if (nextTab) { + void switchTab(nextTab.id).then(renderDocumentSidebar, renderDocumentSidebar); + } else { + clearActiveDocument(); + renderDocumentSidebar(); + } + } else { + renderDocumentSplitView(); + renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + } + announceToScreenReader(idsToClose.size + ' tabs closed. Files remain available in Explorer.'); } function openTabContextMenu(tab, point, focusTab) { @@ -11105,16 +11195,37 @@ ${selector} .arrowheadPath { mediaProgressHideTimeout = setTimeout(function() { toast.hidden = true; }, 4200); } + function createImportedDocument(content, title, location) { + const targetLocation = location || (documentOrganization + ? getPreferredDocumentLocation() + : { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }); + if (targetLocation.workspaceId === SECRET_WORKSPACE_ID && !isSecretWorkspaceUnlocked()) return null; + const tab = createTab(content, title, 'split', targetLocation); + tab.isOpen = false; + normalizeTabDocumentMetadata(tab, { allowSecret: targetLocation.workspaceId === SECRET_WORKSPACE_ID }); + tabs.push(tab); + return tab; + } + + async function persistImportedDocuments(documentIds) { + const ids = Array.from(documentIds || []).filter(Boolean); + if (!ids.length) return; + saveTabsToStorage(tabs, ids); + await _flushTabsToStorage(tabs, { changedIds: ids }); + evictInactiveDocumentContent(); + renderDocumentSidebar(); + } + function importMarkdownFile(file, location) { return new Promise(function(resolve) { if (!file) { - resolve(false); + resolve(null); return; } if (file.size > 10 * 1024 * 1024) { showUnsupportedFileToast('This Markdown file is too large. The maximum supported size is 10 MB.'); - resolve(false); + resolve(null); return; } @@ -11127,16 +11238,16 @@ ${selector} .arrowheadPath { for (let i = 0; i < checkLength; i++) { if (text.charCodeAt(i) === 0) { showUnsupportedFileToast('This file appears to be binary. Choose a Markdown file (.md or .markdown).'); - resolve(false); + resolve(null); return; } } - resolve(newTab(text, getMarkdownFileTitle(file), location) === true); + resolve(createImportedDocument(text, getMarkdownFileTitle(file), location)); }; reader.onerror = function() { alert('Failed to read the file. Please check permissions and try again.'); - resolve(false); + resolve(null); }; reader.readAsText(file); }); @@ -11160,17 +11271,25 @@ ${selector} .arrowheadPath { const filesToImport = markdownFiles; let importedCount = 0; + const importedDocumentIds = []; showImportProgress(filesToImport.length); for (let index = 0; index < filesToImport.length; index++) { - if (await importMarkdownFile(filesToImport[index], settings.location)) { + const importedDocument = await importMarkdownFile(filesToImport[index], settings.location); + if (importedDocument) { importedCount++; + importedDocumentIds.push(importedDocument.id); } updateImportProgress(index + 1, filesToImport.length); } + await persistImportedDocuments(importedDocumentIds); if (settings.location && settings.location.workspaceId === SECRET_WORKSPACE_ID && isSecretWorkspaceUnlocked()) { await flushSecretWorkspaceToStorage(); } - finishImportProgress(importedCount, filesToImport.length); + finishImportProgress(importedCount, filesToImport.length, { + detail: importedCount === filesToImport.length + ? 'Saved to Explorer without opening new tabs.' + : undefined + }); return importedCount; } @@ -11178,11 +11297,22 @@ ${selector} .arrowheadPath { function isMarkdownPath(path) { return /\.(md|markdown)$/i.test(path || ""); } - const MAX_GITHUB_FILES_SHOWN = 30; const GITHUB_IMPORT_MIN_REQUEST_INTERVAL_MS = 800; + const GITHUB_API_VERSION = "2022-11-28"; + const GITHUB_ACCESS_VAULT_NAME = "githubAccessVaultV2"; + const GITHUB_LEGACY_ACCESS_RECORD_NAME = "githubAccessRecordV1"; let lastGitHubImportRequestAt = 0; const selectedGitHubImportPaths = new Set(); + const collapsedGitHubImportFolders = new Set(); let availableGitHubImportPaths = []; + let githubImportSearchTimer = null; + let githubAccessEntries = []; + let githubSelectedAccessId = ""; + let githubAccessAdding = true; + let githubAccessVaultLoaded = false; + let githubAccessVaultLoadPromise = null; + const githubSessionAccessTokens = new Map(); + let githubAccessExpiryTimer = null; function getFileName(path) { return (path || "").split("/").pop() || "document.md"; @@ -11206,11 +11336,37 @@ ${selector} .arrowheadPath { return parentFolder || repositoryFolder; } - async function importGitHubFilesInBackground(owner, repo, ref, filePaths) { + function encodeGitHubPath(path) { + return String(path || "") + .split("/") + .filter(Boolean) + .map((part) => encodeURIComponent(part)) + .join("/"); + } + + function getGitHubRepositoryFolderName(context) { + if (!context || !context.repo) return "GitHub import"; + return context.isDefaultRef || !context.commitSha + ? context.repo + : `${context.repo} @ ${context.commitSha.slice(0, 7)}`; + } + + async function fetchGitHubFileContent(context, filePath) { + if (context.isPrivate) { + const url = `https://api.github.com/repos/${encodeURIComponent(context.owner)}/${encodeURIComponent(context.repo)}/contents/${encodeGitHubPath(filePath)}?ref=${encodeURIComponent(context.commitSha)}`; + return fetchGitHubText(url, { + authenticated: true, + accept: "application/vnd.github.raw+json" + }); + } + return fetchTextContent(buildRawGitHubUrl(context.owner, context.repo, context.commitSha, filePath)); + } + + async function importGitHubFilesInBackground(context, filePaths) { const paths = Array.from(filePaths || []).filter(Boolean); if (!paths.length) return 0; - const folder = ensureWorkspaceFolder(DEFAULT_WORKSPACE_ID, repo); + const folder = ensureWorkspaceFolder(DEFAULT_WORKSPACE_ID, getGitHubRepositoryFolderName(context)); if (!folder) { showImportProgress(paths.length, { title: 'GitHub import', detail: 'Preparing repository folder…' }); finishImportProgress(0, paths.length, { detail: 'The repository folder could not be created.' }); @@ -11224,15 +11380,22 @@ ${selector} .arrowheadPath { }); let imported = 0; + const importedDocumentIds = []; for (let index = 0; index < paths.length; index++) { const filePath = paths[index]; updateImportProgress(index, paths.length, 'Importing ' + getFileName(filePath)); try { - const markdown = await fetchTextContent(buildRawGitHubUrl(owner, repo, ref, filePath)); + const markdown = await fetchGitHubFileContent(context, filePath); const destinationFolder = ensureGitHubFolderPath(DEFAULT_WORKSPACE_ID, folder, filePath); const destination = { workspaceId: DEFAULT_WORKSPACE_ID, folderId: destinationFolder.id }; - if (newTab(markdown, getFileName(filePath).replace(/\.(md|markdown)$/i, ''), destination)) { + const importedDocument = createImportedDocument( + markdown, + getFileName(filePath).replace(/\.(md|markdown)$/i, ''), + destination + ); + if (importedDocument) { imported++; + importedDocumentIds.push(importedDocument.id); } } catch (error) { console.error('GitHub file import failed:', filePath, error); @@ -11240,11 +11403,12 @@ ${selector} .arrowheadPath { updateImportProgress(index + 1, paths.length, 'Saving to Workspace / ' + folder.name); } + await persistImportedDocuments(importedDocumentIds); const failureCount = paths.length - imported; finishImportProgress(imported, paths.length, { detail: failureCount ? failureCount + ' file' + (failureCount === 1 ? '' : 's') + ' could not be imported.' - : 'Saved in Workspace / ' + folder.name + : 'Saved in Workspace / ' + folder.name + ' without opening new tabs.' }); announceToScreenReader(imported === paths.length ? 'GitHub import complete. Files saved in the ' + folder.name + ' folder.' @@ -11252,11 +11416,11 @@ ${selector} .arrowheadPath { return imported; } - function startGitHubImport(owner, repo, ref, filePaths) { + function startGitHubImport(context, filePaths) { const paths = Array.from(filePaths || []).filter(Boolean); closeGitHubImportModal(); if (!paths.length) return; - void importGitHubFilesInBackground(owner, repo, ref, paths).catch(function(error) { + void importGitHubFilesInBackground(context, paths).catch(function(error) { console.error('GitHub import failed:', error); showImportProgress(paths.length, { title: 'GitHub import', detail: 'Unable to start import.' }); finishImportProgress(0, paths.length, { detail: error && error.message ? error.message : 'GitHub import failed.' }); @@ -11264,7 +11428,53 @@ ${selector} .arrowheadPath { }); } - async function fetchGitHubJson(url) { + class GitHubRequestError extends Error { + constructor(status, message) { + super(message || `GitHub API request failed (${status})`); + this.name = "GitHubRequestError"; + this.status = status; + } + } + + function createGitHubAccessId() { + if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") { + return "github_access_" + globalThis.crypto.randomUUID(); + } + return "github_access_" + Date.now() + "_" + Math.random().toString(36).slice(2, 10); + } + + function getSelectedGitHubAccessEntry() { + return githubAccessEntries.find(function(entry) { + return entry.id === githubSelectedAccessId; + }) || null; + } + + function getGitHubAccessToken() { + const expiredEntries = pruneExpiredGitHubAccessEntries(); + if (expiredEntries.length) renderGitHubAccessState(); + return githubSessionAccessTokens.get(githubSelectedAccessId) || ""; + } + + function getGitHubApiHeaders(accept, authenticated) { + const headers = { + Accept: accept || "application/vnd.github+json", + "X-GitHub-Api-Version": GITHUB_API_VERSION + }; + if (authenticated) { + const token = getGitHubAccessToken(); + if (!token) { + throw new Error("The selected GitHub access token is unavailable or expired. Add it again."); + } + headers.Authorization = `Bearer ${token}`; + } + return headers; + } + + async function fetchGitHubResponse(url, options = {}) { + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== "https:" || parsedUrl.origin !== "https://api.github.com") { + throw new Error("Blocked an unsafe GitHub API destination."); + } const now = Date.now(); const waitTime = GITHUB_IMPORT_MIN_REQUEST_INTERVAL_MS - (now - lastGitHubImportRequestAt); if (waitTime > 0) { @@ -11272,24 +11482,55 @@ ${selector} .arrowheadPath { } lastGitHubImportRequestAt = Date.now(); const response = await fetch(url, { - headers: { - Accept: "application/vnd.github+json" - } + headers: getGitHubApiHeaders(options.accept, options.authenticated === true), + redirect: "error", + cache: "no-store" }); if (!response.ok) { - throw new Error(`GitHub API request failed (${response.status})`); + let message = `GitHub API request failed (${response.status})`; + if (response.status === 401) { + message = "GitHub rejected the token. Check that it is active and has read-only repository Contents access."; + } else if (response.status === 403) { + message = "GitHub denied this request. Check token permissions, organization approval, or API rate limits."; + } + throw new GitHubRequestError(response.status, message); } + return response; + } + + async function fetchGitHubJson(url, options = {}) { + const response = await fetchGitHubResponse(url, options); return response.json(); } + async function fetchGitHubText(url, options = {}) { + const response = await fetchGitHubResponse(url, options); + return response.text(); + } + async function fetchTextContent(url) { - const response = await fetch(url); + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== "https:" || parsedUrl.origin !== "https://raw.githubusercontent.com") { + throw new Error("Blocked an unsafe GitHub content destination."); + } + const response = await fetch(url, { + redirect: "error", + cache: "no-store" + }); if (!response.ok) { throw new Error(`Failed to fetch file (${response.status})`); } return response.text(); } + function decodeGitHubPathSegments(pathname) { + try { + return pathname.split("/").filter(Boolean).map((segment) => decodeURIComponent(segment)); + } catch (_) { + return null; + } + } + function parseGitHubImportUrl(input) { let parsedUrl; try { @@ -11298,14 +11539,22 @@ ${selector} .arrowheadPath { return null; } + if (parsedUrl.protocol !== "https:" || parsedUrl.username || parsedUrl.password || parsedUrl.port) { + return null; + } const host = parsedUrl.hostname.replace(/^www\./, ""); - const segments = parsedUrl.pathname.split("/").filter(Boolean); + const segments = decodeGitHubPathSegments(parsedUrl.pathname); + if (!segments) return null; if (host === "raw.githubusercontent.com") { if (segments.length < 4) return null; - const [owner, repo, ref, ...rest] = segments; - const filePath = rest.join("/"); - return { owner, repo, ref, type: "file", filePath }; + return { + owner: segments[0], + repo: segments[1].replace(/\.git$/i, ""), + type: "file", + locationSegments: segments.slice(2), + source: "raw" + }; } if (host !== "github.com" || segments.length < 2) return null; @@ -11322,8 +11571,8 @@ ${selector} .arrowheadPath { owner, repo, type: "file", - ref: segments[3], - filePath: segments.slice(4).join("/") + locationSegments: segments.slice(3), + source: "blob" }; } @@ -11332,26 +11581,455 @@ ${selector} .arrowheadPath { owner, repo, type: "tree", - ref: segments[3], - basePath: segments.slice(4).join("/") + locationSegments: segments.slice(3), + source: "tree" }; } return { owner, repo, type: "repo" }; } - async function getDefaultBranch(owner, repo) { - const repoInfo = await fetchGitHubJson(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`); - return repoInfo.default_branch; + async function getGitHubRepositoryInfo(owner, repo) { + const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + try { + return { + info: await fetchGitHubJson(url), + authenticated: false + }; + } catch (error) { + const canRetryWithToken = error instanceof GitHubRequestError + && (error.status === 403 || error.status === 404) + && Boolean(getGitHubAccessToken()); + if (!canRetryWithToken) { + if (error instanceof GitHubRequestError && error.status === 404) { + const selectedAccess = getSelectedGitHubAccessEntry(); + if (selectedAccess && !githubSessionAccessTokens.has(selectedAccess.id)) { + throw new Error(`"${selectedAccess.name}" is no longer available. Add the token again.`); + } + throw new Error("Repository not found. If it is private, add secure private repository access and try again."); + } + throw error; + } + try { + return { + info: await fetchGitHubJson(url, { authenticated: true }), + authenticated: true + }; + } catch (authenticatedError) { + const selectedAccess = getSelectedGitHubAccessEntry(); + const accessName = selectedAccess ? `"${selectedAccess.name}"` : "The selected token"; + if (authenticatedError instanceof GitHubRequestError && authenticatedError.status === 401) { + throw new Error(`${accessName} was rejected by GitHub. Replace or remove it.`); + } + if (authenticatedError instanceof GitHubRequestError + && (authenticatedError.status === 403 || authenticatedError.status === 404)) { + throw new Error(`${accessName} cannot access ${owner}/${repo}. Check its repository access and permissions.`); + } + throw authenticatedError; + } + } + } + + async function resolveGitHubCommit(owner, repo, refName, authenticated) { + const commit = await fetchGitHubJson( + `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits/${encodeURIComponent(refName)}`, + { authenticated } + ); + if (!commit || !commit.sha) { + throw new Error("GitHub did not return a commit for this branch, tag, or commit reference."); + } + return commit; + } + + async function resolveGitHubLocation(parsed) { + const repository = await getGitHubRepositoryInfo(parsed.owner, parsed.repo); + const repoInfo = repository.info || {}; + const authenticated = Boolean(repository.authenticated || repoInfo.private); + const defaultBranch = repoInfo.default_branch || "main"; + let refName = defaultBranch; + let commit = null; + let remainder = []; + + if (parsed.type === "repo") { + commit = await resolveGitHubCommit(parsed.owner, parsed.repo, refName, authenticated); + } else { + const segments = Array.from(parsed.locationSegments || []).filter(Boolean); + const minimumRemainder = parsed.type === "file" ? 1 : 0; + const maximumRefSegments = segments.length - minimumRemainder; + for (let count = maximumRefSegments; count >= 1; count--) { + const candidate = segments.slice(0, count).join("/"); + try { + commit = await resolveGitHubCommit(parsed.owner, parsed.repo, candidate, authenticated); + refName = candidate; + remainder = segments.slice(count); + break; + } catch (error) { + if (!(error instanceof GitHubRequestError) || error.status !== 404) throw error; + } + } + if (!commit) { + throw new Error("No matching branch, tag, or commit was found in this GitHub URL."); + } + } + + return { + owner: parsed.owner, + repo: parsed.repo, + type: parsed.type, + refName, + commitSha: commit.sha, + defaultBranch, + isDefaultRef: refName === defaultBranch, + hasExplicitRef: parsed.type !== "repo", + isPrivate: Boolean(repoInfo.private), + authenticated, + filePath: parsed.type === "file" ? remainder.join("/") : "", + basePath: parsed.type === "tree" ? remainder.join("/") : "" + }; + } + + function toGitHubExpiryInputValue(timestamp) { + const date = new Date(timestamp); + const localDate = new Date(date.getTime() - (date.getTimezoneOffset() * 60000)); + return localDate.toISOString().slice(0, 16); + } + + function parseGitHubAccessExpiry(value) { + if (!value) return null; + const expiresAt = new Date(value).getTime(); + if (!Number.isFinite(expiresAt)) { + throw new Error("Enter a valid expiry date."); + } + if (expiresAt <= Date.now()) { + throw new Error("Choose an expiry date in the future."); + } + return expiresAt; + } + + function formatGitHubAccessExpiry(expiresAt) { + if (!expiresAt || !Number.isFinite(Number(expiresAt))) return "Available for this session"; + try { + return "Expires " + new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short" + }).format(new Date(expiresAt)); + } catch (_) { + return "Expires " + new Date(expiresAt).toLocaleString(); + } + } + + function pruneExpiredGitHubAccessEntries(options = {}) { + const expiredEntries = githubAccessEntries.filter(function(entry) { + return Number(entry.expiresAt) > 0 && Number(entry.expiresAt) <= Date.now(); + }); + if (!expiredEntries.length) return []; + const expiredIds = new Set(expiredEntries.map(function(entry) { return entry.id; })); + githubAccessEntries = githubAccessEntries.filter(function(entry) { + return !expiredIds.has(entry.id); + }); + expiredIds.forEach(function(id) { + githubSessionAccessTokens.delete(id); + }); + if (!githubAccessEntries.some(function(entry) { return entry.id === githubSelectedAccessId; })) { + githubSelectedAccessId = githubAccessEntries[0] ? githubAccessEntries[0].id : ""; + } + githubAccessAdding = !githubAccessEntries.length; + if (options.notify !== false) { + const message = expiredEntries.length === 1 + ? `"${expiredEntries[0].name}" expired and was removed.` + : `${expiredEntries.length} GitHub access tokens expired and were removed.`; + showAppToast(message, { + tone: "warning", + title: "GitHub access expired", + dedupeKey: "github-access-expired" + }); + } + return expiredEntries; + } + + function scheduleGitHubAccessExpiry() { + clearTimeout(githubAccessExpiryTimer); + githubAccessExpiryTimer = null; + const expiries = githubAccessEntries + .map(function(entry) { return Number(entry.expiresAt) || 0; }) + .filter(function(expiresAt) { return expiresAt > 0; }); + if (!expiries.length) return; + const delay = Math.max(0, Math.min(Math.min.apply(Math, expiries) - Date.now(), 2147483647)); + githubAccessExpiryTimer = setTimeout(function() { + const expiredEntries = pruneExpiredGitHubAccessEntries(); + if (expiredEntries.length) renderGitHubAccessState(); + else scheduleGitHubAccessExpiry(); + }, delay); + } + + function resetGitHubAccessForm() { + if (githubImportPatNameInput) githubImportPatNameInput.value = ""; + if (githubImportPatExpiryInput) { + githubImportPatExpiryInput.value = ""; + githubImportPatExpiryInput.min = toGitHubExpiryInputValue(Date.now() + 60000); + } + if (githubImportPatInput) githubImportPatInput.value = ""; } - async function listMarkdownFiles(owner, repo, ref, basePath) { - const treeResponse = await fetchGitHubJson(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(ref)}?recursive=1`); + function setGitHubAccessAdding(isAdding, options = {}) { + githubAccessAdding = Boolean(isAdding) || !githubAccessEntries.length; + if (githubAccessAdding && options.reset !== false) resetGitHubAccessForm(); + renderGitHubAccessState(); + if (githubAccessAdding && options.focus !== false && githubImportPatNameInput) { + requestAnimationFrame(function() { + githubImportPatNameInput.focus(); + githubImportPatNameInput.scrollIntoView({ block: "nearest" }); + }); + } + } + + function renderGitHubAccessState() { + pruneExpiredGitHubAccessEntries(); + githubAccessEntries = githubAccessEntries.filter(function(entry) { + return githubSessionAccessTokens.has(entry.id); + }); + if (!githubAccessEntries.some(function(entry) { return entry.id === githubSelectedAccessId; })) { + githubSelectedAccessId = githubAccessEntries[0] ? githubAccessEntries[0].id : ""; + } + + const selectedEntry = getSelectedGitHubAccessEntry(); + const hasEntries = githubAccessEntries.length > 0; + githubAccessAdding = githubAccessAdding || !hasEntries; + + if (githubImportAccess) { + githubImportAccess.dataset.state = selectedEntry ? "active" : "empty"; + } + if (githubImportAccessSummary) { + githubImportAccessSummary.textContent = selectedEntry + ? `${selectedEntry.name} · Ready` + : "No access token added"; + } + if (githubImportAccessManage) { + githubImportAccessManage.hidden = !hasEntries || githubAccessAdding; + } + if (githubImportAccessForm) { + githubImportAccessForm.hidden = hasEntries && !githubAccessAdding; + } + if (githubImportPatAddCancelBtn) { + githubImportPatAddCancelBtn.hidden = !hasEntries; + } + if (githubImportPatSelect) { + githubImportPatSelect.replaceChildren(); + githubAccessEntries.forEach(function(entry) { + const option = document.createElement("option"); + option.value = entry.id; + option.textContent = entry.name; + githubImportPatSelect.appendChild(option); + }); + githubImportPatSelect.value = githubSelectedAccessId; + } + if (githubImportAccessSelectedMeta) { + githubImportAccessSelectedMeta.textContent = selectedEntry + ? formatGitHubAccessExpiry(selectedEntry.expiresAt) + : ""; + } + if (githubImportPatRemoveBtn) { + githubImportPatRemoveBtn.hidden = !selectedEntry; + } + scheduleGitHubAccessExpiry(); + } + + async function loadStoredGitHubAccess() { + if (githubAccessVaultLoaded) { + renderGitHubAccessState(); + return; + } + if (githubAccessVaultLoadPromise) { + await githubAccessVaultLoadPromise; + return; + } + githubAccessVaultLoadPromise = (async function() { + if (!workspaceStorage) { + githubAccessVaultLoaded = true; + renderGitHubAccessState(); + return; + } + try { + await workspaceStorage.init(); + const vault = await workspaceStorage.getMetadata(GITHUB_ACCESS_VAULT_NAME); + const legacyRecord = await workspaceStorage.getMetadata(GITHUB_LEGACY_ACCESS_RECORD_NAME); + if (vault || legacyRecord) { + if (typeof workspaceStorage.deleteMetadata === "function") { + await workspaceStorage.deleteMetadata(GITHUB_ACCESS_VAULT_NAME); + await workspaceStorage.deleteMetadata(GITHUB_LEGACY_ACCESS_RECORD_NAME); + } else { + await workspaceStorage.setMetadata(GITHUB_ACCESS_VAULT_NAME, null); + await workspaceStorage.setMetadata(GITHUB_LEGACY_ACCESS_RECORD_NAME, null); + } + } + githubAccessVaultLoaded = true; + renderGitHubAccessState(); + } catch (_) { + githubAccessVaultLoaded = true; + showAppToast("Old saved GitHub access could not be cleared.", { + tone: "error", + title: "GitHub access cleanup failed", + dedupeKey: "github-access-cleanup-failed" + }); + renderGitHubAccessState(); + } + })(); + try { + await githubAccessVaultLoadPromise; + } finally { + githubAccessVaultLoadPromise = null; + } + } + + async function removeGitHubAccess(options = {}) { + const accessId = options.id || githubSelectedAccessId; + const removedEntry = githubAccessEntries.find(function(entry) { return entry.id === accessId; }); + if (!removedEntry) return; + githubAccessEntries = githubAccessEntries.filter(function(entry) { return entry.id !== accessId; }); + githubSessionAccessTokens.delete(accessId); + githubSelectedAccessId = githubAccessEntries[0] ? githubAccessEntries[0].id : ""; + githubAccessAdding = !githubAccessEntries.length; + renderGitHubAccessState(); + if (!options.silent) { + showAppToast(`"${removedEntry.name}" was removed.`, { + tone: "success", + title: "GitHub access removed", + icon: "lucide-check-check", + dedupeKey: "github-access-feedback" + }); + announceToScreenReader("GitHub access token removed."); + } + } + + function validateGitHubPat(token) { + if (!token || token.length < 20 || /\s/.test(token)) { + throw new Error("Enter a valid personal access token without spaces."); + } + } + + function validateGitHubAccessName(name) { + const normalizedName = String(name || "").trim().replace(/\s+/g, " "); + if (!normalizedName) throw new Error("Enter a token name."); + if (normalizedName.length > 60) throw new Error("Keep the token name to 60 characters or fewer."); + if (githubAccessEntries.some(function(entry) { + return entry.name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase(); + })) { + throw new Error("Use a unique token name."); + } + return normalizedName; + } + + async function saveGitHubAccess() { + if (!githubImportPatInput || !githubImportPatNameInput) return; + const name = validateGitHubAccessName(githubImportPatNameInput.value); + const token = githubImportPatInput.value; + const expiresAt = parseGitHubAccessExpiry(githubImportPatExpiryInput ? githubImportPatExpiryInput.value : ""); + validateGitHubPat(token); + const entry = { + id: createGitHubAccessId(), + name, + expiresAt, + createdAt: Date.now() + }; + + githubAccessEntries.push(entry); + githubSessionAccessTokens.set(entry.id, token); + githubSelectedAccessId = entry.id; + githubAccessAdding = false; + resetGitHubAccessForm(); + renderGitHubAccessState(); + showAppToast(`"${name}" is ready for this session.`, { + tone: "success", + title: "GitHub access added", + icon: "lucide-check-check", + dedupeKey: "github-access-feedback" + }); + announceToScreenReader("GitHub access token added."); + } + + function renderGitHubRepositoryContext(context) { + if (!githubImportRepositoryContext) return; + if (!context) { + githubImportRepositoryContext.hidden = true; + return; + } + githubImportRepositoryContext.hidden = false; + if (githubImportRepositoryName) { + githubImportRepositoryName.textContent = `${context.owner}/${context.repo}`; + } + if (githubImportCommit) { + githubImportCommit.textContent = context.commitSha.slice(0, 7); + } + if (githubImportCommitLink) { + githubImportCommitLink.href = `https://github.com/${encodeURIComponent(context.owner)}/${encodeURIComponent(context.repo)}/tree/${encodeURIComponent(context.commitSha)}`; + githubImportCommitLink.title = `Open commit ${context.commitSha} on GitHub`; + githubImportCommitLink.setAttribute("aria-label", `Open commit ${context.commitSha} on GitHub`); + } + if (githubImportRef) { + githubImportRef.textContent = context.refName; + } + if (githubImportRefItem) { + githubImportRefItem.hidden = !context.hasExplicitRef; + } + if (githubImportBasePath) { + githubImportBasePath.textContent = context.basePath ? `/ ${context.basePath}` : ""; + githubImportBasePath.hidden = !context.basePath; + } + } + + function isPathInsideGitHubBase(path, normalizedBasePath) { + return !normalizedBasePath || path === normalizedBasePath || path.startsWith(normalizedBasePath + "/"); + } + + function shouldTraverseGitHubTree(path, normalizedBasePath) { + if (!normalizedBasePath) return true; + return path === normalizedBasePath + || normalizedBasePath.startsWith(path + "/") + || path.startsWith(normalizedBasePath + "/"); + } + + async function listMarkdownFilesFromGitHubSubtrees(owner, repo, ref, normalizedBasePath, authenticated) { + const pendingTrees = [{ path: "", ref }]; + const markdownPaths = new Set(); + + for (let index = 0; index < pendingTrees.length; index++) { + const currentTree = pendingTrees[index]; + const treeResponse = await fetchGitHubJson( + `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(currentTree.ref)}`, + { authenticated } + ); + + (treeResponse.tree || []).forEach((entry) => { + const fullPath = currentTree.path ? `${currentTree.path}/${entry.path}` : entry.path; + if (entry.type === "blob" && isMarkdownPath(fullPath) && isPathInsideGitHubBase(fullPath, normalizedBasePath)) { + markdownPaths.add(fullPath); + } else if (entry.type === "tree" && shouldTraverseGitHubTree(fullPath, normalizedBasePath)) { + pendingTrees.push({ path: fullPath, ref: entry.sha }); + } + }); + } + + return Array.from(markdownPaths).sort((a, b) => a.localeCompare(b)); + } + + async function listMarkdownFiles(owner, repo, ref, basePath, options = {}) { + const authenticated = options.authenticated === true; + const treeResponse = await fetchGitHubJson( + `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(ref)}?recursive=1`, + { authenticated } + ); const normalizedBasePath = (basePath || "").replace(/^\/+|\/+$/g, ""); + if (treeResponse.truncated) { + if (typeof options.onTruncatedTree === "function") { + options.onTruncatedTree(); + } + return listMarkdownFilesFromGitHubSubtrees(owner, repo, ref, normalizedBasePath, authenticated); + } + return (treeResponse.tree || []) .filter((entry) => entry.type === "blob" && isMarkdownPath(entry.path)) - .filter((entry) => !normalizedBasePath || entry.path === normalizedBasePath || entry.path.startsWith(normalizedBasePath + "/")) + .filter((entry) => isPathInsideGitHubBase(entry.path, normalizedBasePath)) .map((entry) => entry.path) .sort((a, b) => a.localeCompare(b)); } @@ -11377,14 +12055,21 @@ ${selector} .arrowheadPath { function updateGitHubImportSelectedCount() { if (!githubImportSelectedCount) return; const count = selectedGitHubImportPaths.size; - githubImportSelectedCount.textContent = `${count} selected`; + githubImportSelectedCount.textContent = `${count.toLocaleString()} selected`; } function updateGitHubSelectAllButtonLabel() { if (!githubImportSelectAllBtn) return; const total = availableGitHubImportPaths.length; const allSelected = total > 0 && selectedGitHubImportPaths.size === total; - githubImportSelectAllBtn.textContent = allSelected ? "Clear All" : "Select All"; + const actionLabel = allSelected ? "Deselect all files" : "Select all files"; + githubImportSelectAllBtn.setAttribute("aria-label", actionLabel); + githubImportSelectAllBtn.title = actionLabel; + githubImportSelectAllBtn.setAttribute("aria-pressed", allSelected ? "true" : "false"); + const icon = githubImportSelectAllBtn.querySelector("i"); + if (icon) { + icon.className = `lucide ${allSelected ? "lucide-square-x" : "lucide-check-check"}`; + } } function syncGitHubSelectionToButtons() { @@ -11393,6 +12078,9 @@ ${selector} .arrowheadPath { const isSelected = selectedGitHubImportPaths.has(btn.dataset.path); btn.classList.toggle("is-selected", isSelected); btn.setAttribute("aria-pressed", isSelected ? "true" : "false"); + btn.setAttribute("aria-label", `${btn.dataset.name || getFileName(btn.dataset.path)}, ${isSelected ? "selected" : "not selected"}`); + const checkIcon = btn.querySelector(".github-tree-selection-icon"); + if (checkIcon) checkIcon.hidden = !isSelected; }); } @@ -11416,22 +12104,126 @@ ${selector} .arrowheadPath { updateGitHubSelectAllButtonLabel(); } + function getFilteredGitHubImportPaths() { + const query = githubImportSearchInput ? githubImportSearchInput.value.trim().toLocaleLowerCase() : ""; + if (!query) return availableGitHubImportPaths.slice(); + return availableGitHubImportPaths.filter((path) => path.toLocaleLowerCase().includes(query)); + } + + function getGitHubImportFolderPaths(paths) { + const folderPaths = new Set(); + (paths || []).forEach(function(path) { + const segments = String(path || "").split("/").filter(Boolean); + segments.pop(); + for (let index = 1; index <= segments.length; index++) { + folderPaths.add(segments.slice(0, index).join("/")); + } + }); + return Array.from(folderPaths); + } + + function updateGitHubFolderToggleButton() { + if (!githubImportToggleFoldersBtn) return; + const folderPaths = getGitHubImportFolderPaths(availableGitHubImportPaths); + const searchActive = Boolean(githubImportSearchInput && githubImportSearchInput.value.trim()); + const allCollapsed = folderPaths.length > 0 && folderPaths.every(function(path) { + return collapsedGitHubImportFolders.has(path); + }); + const actionLabel = allCollapsed ? "Expand all folders" : "Collapse all folders"; + githubImportToggleFoldersBtn.disabled = searchActive || !folderPaths.length; + githubImportToggleFoldersBtn.setAttribute("aria-label", actionLabel); + githubImportToggleFoldersBtn.title = searchActive ? "Clear search to change all folders" : actionLabel; + githubImportToggleFoldersBtn.setAttribute("aria-pressed", allCollapsed ? "true" : "false"); + const icon = githubImportToggleFoldersBtn.querySelector("i"); + if (icon) { + icon.className = `lucide ${allCollapsed ? "lucide-unfold-vertical" : "lucide-fold-vertical"}`; + } + } + + function toggleAllGitHubImportFolders() { + const folderPaths = getGitHubImportFolderPaths(availableGitHubImportPaths); + if (!folderPaths.length) return; + const shouldExpand = folderPaths.every(function(path) { + return collapsedGitHubImportFolders.has(path); + }); + collapsedGitHubImportFolders.clear(); + if (!shouldExpand) { + folderPaths.forEach(function(path) { + collapsedGitHubImportFolders.add(path); + }); + } + renderGitHubImportTree(); + announceToScreenReader(shouldExpand ? "All GitHub folders expanded." : "All GitHub folders collapsed."); + } + + function countMarkdownFilesInTreeNode(node) { + return node.files.length + Object.values(node.folders).reduce((total, folder) => { + return total + countMarkdownFilesInTreeNode(folder); + }, 0); + } + function renderGitHubImportTree(paths) { if (!githubImportTree || !githubImportFileSelect) return; - githubImportTree.innerHTML = ""; - const tree = buildMarkdownFileTree(paths); + githubImportTree.textContent = ""; + const visiblePaths = Array.isArray(paths) ? paths : getFilteredGitHubImportPaths(); + const searchActive = Boolean(githubImportSearchInput && githubImportSearchInput.value.trim()); + if (!visiblePaths.length) { + const emptyState = document.createElement("div"); + emptyState.className = "github-import-tree-empty"; + emptyState.setAttribute("role", "status"); + emptyState.innerHTML = 'No Markdown files match your search.'; + githubImportTree.appendChild(emptyState); + updateGitHubFolderToggleButton(); + return; + } + const tree = buildMarkdownFileTree(visiblePaths); const createTreeBranch = function(node, parentPath) { const list = document.createElement("ul"); + list.setAttribute("role", "group"); const folderNames = Object.keys(node.folders).sort((a, b) => a.localeCompare(b)); folderNames.forEach((folderName) => { const folderPath = parentPath ? `${parentPath}/${folderName}` : folderName; const item = document.createElement("li"); - const folderLabel = document.createElement("span"); - folderLabel.className = "github-tree-folder-label"; - folderLabel.textContent = `📁 ${folderName}`; - item.appendChild(folderLabel); - item.appendChild(createTreeBranch(node.folders[folderName], folderPath)); + item.className = "github-tree-folder-item"; + item.setAttribute("role", "none"); + const folderToggle = document.createElement("button"); + const childBranch = createTreeBranch(node.folders[folderName], folderPath); + const collapsed = !searchActive && collapsedGitHubImportFolders.has(folderPath); + folderToggle.type = "button"; + folderToggle.className = "github-tree-folder-label"; + folderToggle.dataset.folderPath = folderPath; + folderToggle.setAttribute("role", "treeitem"); + folderToggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); + folderToggle.title = folderPath; + + const chevron = document.createElement("i"); + chevron.className = `lucide ${collapsed ? "lucide-chevron-right" : "lucide-chevron-down"} github-tree-chevron`; + chevron.setAttribute("aria-hidden", "true"); + const folderIcon = document.createElement("i"); + folderIcon.className = `lucide ${collapsed ? "lucide-folder" : "lucide-folder-open"} github-tree-folder-icon`; + folderIcon.setAttribute("aria-hidden", "true"); + const folderNameNode = document.createElement("span"); + folderNameNode.className = "github-import-tree-name"; + folderNameNode.textContent = folderName; + const folderCount = document.createElement("span"); + folderCount.className = "github-tree-folder-count"; + folderCount.textContent = countMarkdownFilesInTreeNode(node.folders[folderName]).toLocaleString(); + folderCount.setAttribute("aria-label", `${folderCount.textContent} Markdown files`); + + childBranch.hidden = collapsed; + folderToggle.append(chevron, folderIcon, folderNameNode, folderCount); + folderToggle.addEventListener("click", function() { + const shouldCollapse = folderToggle.getAttribute("aria-expanded") === "true"; + folderToggle.setAttribute("aria-expanded", shouldCollapse ? "false" : "true"); + childBranch.hidden = shouldCollapse; + chevron.className = `lucide ${shouldCollapse ? "lucide-chevron-right" : "lucide-chevron-down"} github-tree-chevron`; + folderIcon.className = `lucide ${shouldCollapse ? "lucide-folder" : "lucide-folder-open"} github-tree-folder-icon`; + if (shouldCollapse) collapsedGitHubImportFolders.add(folderPath); + else collapsedGitHubImportFolders.delete(folderPath); + updateGitHubFolderToggleButton(); + }); + item.append(folderToggle, childBranch); list.appendChild(item); }); @@ -11439,12 +12231,29 @@ ${selector} .arrowheadPath { .sort((a, b) => a.path.localeCompare(b.path)) .forEach((file) => { const fileItem = document.createElement("li"); + fileItem.className = "github-tree-file-item"; + fileItem.setAttribute("role", "none"); const fileButton = document.createElement("button"); fileButton.type = "button"; fileButton.className = "github-tree-file-btn"; fileButton.dataset.path = file.path; + fileButton.dataset.name = file.name; + fileButton.setAttribute("role", "treeitem"); fileButton.setAttribute("aria-pressed", "false"); - fileButton.textContent = `📄 ${file.name}`; + fileButton.title = file.path; + + const fileIcon = document.createElement("i"); + fileIcon.className = "lucide lucide-file-text github-tree-file-icon"; + fileIcon.setAttribute("aria-hidden", "true"); + const fileName = document.createElement("span"); + fileName.className = "github-import-tree-name"; + fileName.textContent = file.name; + const selectedIcon = document.createElement("i"); + selectedIcon.className = "lucide lucide-check github-tree-selection-icon"; + selectedIcon.setAttribute("aria-hidden", "true"); + selectedIcon.hidden = true; + + fileButton.append(fileIcon, fileName, selectedIcon); fileButton.addEventListener("click", function() { toggleGitHubSelectedPath(file.path); }); @@ -11455,8 +12264,11 @@ ${selector} .arrowheadPath { return list; }; - githubImportTree.appendChild(createTreeBranch(tree, "")); + const treeFragment = document.createDocumentFragment(); + treeFragment.appendChild(createTreeBranch(tree, "")); + githubImportTree.appendChild(treeFragment); syncGitHubSelectionToButtons(); + updateGitHubFolderToggleButton(); } function setGitHubImportLoading(isLoading) { @@ -11487,12 +12299,22 @@ ${selector} .arrowheadPath { function resetGitHubImportModal() { if (!githubImportUrlInput || !githubImportFileSelect || !githubImportSubmitBtn) return; + if (githubImportModal) githubImportModal.classList.remove("is-selection-step"); if (githubImportTitle) { githubImportTitle.textContent = "Import Markdown from GitHub"; } + if (githubImportSubtitle) { + githubImportSubtitle.textContent = "Paste a GitHub file or repository URL."; + } githubImportUrlInput.value = ""; githubImportUrlInput.style.display = "block"; if (githubImportUrlLabel) githubImportUrlLabel.style.display = "block"; + if (githubImportAccess) githubImportAccess.hidden = false; + if (githubImportAccessToggle) githubImportAccessToggle.setAttribute("aria-expanded", "false"); + if (githubImportAccessPanel) githubImportAccessPanel.hidden = true; + resetGitHubAccessForm(); + githubAccessAdding = !githubAccessEntries.length; + renderGitHubRepositoryContext(null); githubImportUrlInput.disabled = false; githubImportFileSelect.innerHTML = ""; githubImportFileSelect.style.display = "none"; @@ -11501,6 +12323,14 @@ ${selector} .arrowheadPath { githubImportSelectionToolbar.style.display = "none"; } availableGitHubImportPaths = []; + collapsedGitHubImportFolders.clear(); + clearTimeout(githubImportSearchTimer); + githubImportSearchTimer = null; + if (githubImportSearchInput) { + githubImportSearchInput.value = ""; + githubImportSearchInput.disabled = false; + } + updateGitHubFolderToggleButton(); setGitHubSelectedPaths([]); if (githubImportTree) { githubImportTree.innerHTML = ""; @@ -11510,8 +12340,10 @@ ${selector} .arrowheadPath { delete githubImportSubmitBtn.dataset.owner; delete githubImportSubmitBtn.dataset.repo; delete githubImportSubmitBtn.dataset.ref; + githubImportSubmitBtn._githubContext = null; githubImportSubmitBtn.textContent = "Import"; setGitHubImportMessage(""); + renderGitHubAccessState(); } function openGitHubImportModal() { @@ -11521,6 +12353,7 @@ ${selector} .arrowheadPath { focusTarget: githubImportUrlInput, onClose: closeGitHubImportModal }); + void loadStoredGitHubAccess(); } function closeGitHubImportModal() { @@ -11539,19 +12372,37 @@ ${selector} .arrowheadPath { if (githubImportSelectAllBtn) { githubImportSelectAllBtn.disabled = disabled; } + if (githubImportToggleFoldersBtn) { + githubImportToggleFoldersBtn.disabled = disabled; + } + if (githubImportSearchInput) { + githubImportSearchInput.disabled = disabled; + } + [ + githubImportAccessToggle, + githubImportPatSelect, + githubImportPatAddAnotherBtn, + githubImportPatAddCancelBtn, + githubImportPatNameInput, + githubImportPatExpiryInput, + githubImportPatInput, + githubImportPatSaveBtn, + githubImportPatRemoveBtn + ].forEach(function(control) { + if (control) control.disabled = disabled; + }); + if (!disabled) updateGitHubFolderToggleButton(); }; const step = githubImportSubmitBtn.dataset.step || "url"; if (step === "select") { const selectedPaths = Array.from(selectedGitHubImportPaths); - const owner = githubImportSubmitBtn.dataset.owner; - const repo = githubImportSubmitBtn.dataset.repo; - const ref = githubImportSubmitBtn.dataset.ref; - if (!owner || !repo || !ref || !selectedPaths.length) { + const context = githubImportSubmitBtn._githubContext; + if (!context || !context.owner || !context.repo || !context.commitSha || !selectedPaths.length) { setGitHubImportMessage("Please select at least one file to import."); return; } announceToScreenReader("Importing selected files from GitHub..."); - startGitHubImport(owner, repo, ref, selectedPaths); + startGitHubImport(context, selectedPaths); return; } @@ -11571,12 +12422,13 @@ ${selector} .arrowheadPath { setGitHubImportLoading(true); setGitHubImportDialogDisabled(true); try { - if (parsed.type === "file") { - if (!isMarkdownPath(parsed.filePath)) { + const context = await resolveGitHubLocation(parsed); + if (context.type === "file") { + if (!isMarkdownPath(context.filePath)) { throw new Error("The provided URL does not point to a Markdown file."); } announceToScreenReader("Starting GitHub import..."); - startGitHubImport(parsed.owner, parsed.repo, parsed.ref, [parsed.filePath]); + startGitHubImport(context, [context.filePath]); return; } @@ -11590,8 +12442,13 @@ ${selector} .arrowheadPath { githubImportTree.style.display = "block"; } - const ref = parsed.ref || await getDefaultBranch(parsed.owner, parsed.repo); - const files = await listMarkdownFiles(parsed.owner, parsed.repo, ref, parsed.basePath || ""); + const files = await listMarkdownFiles(context.owner, context.repo, context.commitSha, context.basePath, { + authenticated: context.authenticated, + onTruncatedTree: function() { + setGitHubImportMessage("GitHub returned a partial tree. Scanning every folder to find all Markdown files\u2026", { isError: false }); + announceToScreenReader("Large GitHub repository detected. Scanning every folder."); + } + }); if (!files.length) { if (githubImportTree) { @@ -11603,53 +12460,56 @@ ${selector} .arrowheadPath { return; } - const shownFiles = files.slice(0, MAX_GITHUB_FILES_SHOWN); if (files.length === 1) { const targetPath = files[0]; announceToScreenReader("Starting GitHub import..."); - startGitHubImport(parsed.owner, parsed.repo, ref, [targetPath]); + startGitHubImport(context, [targetPath]); return; } githubImportFileSelect.innerHTML = ""; + if (githubImportModal) githubImportModal.classList.add("is-selection-step"); githubImportUrlInput.style.display = "none"; if (githubImportUrlLabel) githubImportUrlLabel.style.display = "none"; + if (githubImportAccess) githubImportAccess.hidden = true; + renderGitHubRepositoryContext(context); githubImportFileSelect.style.display = "none"; if (githubImportSelectionToolbar) { - githubImportSelectionToolbar.style.display = "flex"; + githubImportSelectionToolbar.style.display = "grid"; } if (githubImportTree) { githubImportTree.style.display = "block"; } - shownFiles.forEach((filePath) => { - const option = document.createElement("option"); - option.value = filePath; - option.textContent = filePath; - githubImportFileSelect.appendChild(option); - }); - availableGitHubImportPaths = shownFiles.slice(); - setGitHubSelectedPaths(shownFiles[0] ? [shownFiles[0]] : []); - renderGitHubImportTree(shownFiles); + availableGitHubImportPaths = files.slice(); + setGitHubSelectedPaths(files[0] ? [files[0]] : []); + renderGitHubImportTree(); // Announce load complete announceToScreenReader("GitHub files loaded. " + files.length + " files available in the tree."); - if (files.length > MAX_GITHUB_FILES_SHOWN) { - setGitHubImportMessage(`Showing first ${MAX_GITHUB_FILES_SHOWN} of ${files.length} Markdown files.`, { isError: false }); - } else { - setGitHubImportMessage(""); - } + setGitHubImportMessage(""); if (githubImportTitle) { - githubImportTitle.textContent = "Select Markdown file(s) to import"; + githubImportTitle.textContent = "Select Markdown files to import"; + } + if (githubImportSubtitle) { + githubImportSubtitle.textContent = `${files.length.toLocaleString()} Markdown files found. Choose what to save to Explorer.`; } githubImportSubmitBtn.dataset.step = "select"; - githubImportSubmitBtn.dataset.owner = parsed.owner; - githubImportSubmitBtn.dataset.repo = parsed.repo; - githubImportSubmitBtn.dataset.ref = ref; + githubImportSubmitBtn.dataset.owner = context.owner; + githubImportSubmitBtn.dataset.repo = context.repo; + githubImportSubmitBtn.dataset.ref = context.commitSha; + githubImportSubmitBtn._githubContext = context; githubImportSubmitBtn.textContent = "Import Selected"; + if (githubImportSearchInput) githubImportSearchInput.focus(); } catch (error) { console.error("GitHub import failed:", error); setGitHubImportMessage("GitHub import failed: " + error.message); + if (githubImportAccessToggle && githubImportAccessPanel + && /^".+" (?:is no longer available|cannot access|was rejected)/.test(error.message || "")) { + githubImportAccessToggle.setAttribute("aria-expanded", "true"); + githubImportAccessPanel.hidden = false; + renderGitHubAccessState(); + } announceToScreenReader("GitHub import failed."); if (githubImportTree) { githubImportTree.innerHTML = ""; @@ -17825,11 +18685,26 @@ ${selector} .arrowheadPath { multiSelections: true }); if (result && result.length > 0) { - for (const filePath of result) { - const content = await Neutralino.filesystem.readFile(filePath); - const fileName = filePath.split(/[/\\]/).pop().replace(/\.(md|markdown)$/i, ""); - if (!newTab(content, fileName)) break; + const importedDocumentIds = []; + showImportProgress(result.length); + for (let index = 0; index < result.length; index++) { + const filePath = result[index]; + try { + const content = await Neutralino.filesystem.readFile(filePath); + const fileName = filePath.split(/[/\\]/).pop().replace(/\.(md|markdown)$/i, ""); + const importedDocument = createImportedDocument(content, fileName); + if (importedDocument) importedDocumentIds.push(importedDocument.id); + } catch (fileError) { + console.error("Native file import failed:", filePath, fileError); + } + updateImportProgress(index + 1, result.length); } + await persistImportedDocuments(importedDocumentIds); + finishImportProgress(importedDocumentIds.length, result.length, { + detail: importedDocumentIds.length === result.length + ? 'Saved to Explorer without opening new tabs.' + : undefined + }); } } catch (e) { console.error("Native import failed:", e); @@ -17864,6 +18739,84 @@ ${selector} .arrowheadPath { if (githubImportCloseBtn) { githubImportCloseBtn.addEventListener("click", closeGitHubImportModal); } + if (githubImportAccessToggle && githubImportAccessPanel) { + githubImportAccessToggle.addEventListener("click", function() { + const expanded = githubImportAccessToggle.getAttribute("aria-expanded") === "true"; + githubImportAccessToggle.setAttribute("aria-expanded", expanded ? "false" : "true"); + githubImportAccessPanel.hidden = expanded; + if (!expanded) { + const target = githubAccessAdding ? githubImportPatNameInput : githubImportPatSelect; + if (target) { + requestAnimationFrame(function() { + target.focus(); + target.scrollIntoView({ block: "nearest" }); + }); + } + } + }); + } + if (githubImportPatSelect) { + githubImportPatSelect.addEventListener("change", function() { + githubSelectedAccessId = githubImportPatSelect.value; + renderGitHubAccessState(); + }); + } + if (githubImportPatAddAnotherBtn) { + githubImportPatAddAnotherBtn.addEventListener("click", function() { + setGitHubAccessAdding(true); + }); + } + if (githubImportPatAddCancelBtn) { + githubImportPatAddCancelBtn.addEventListener("click", function() { + setGitHubAccessAdding(false, { focus: false }); + }); + } + if (githubImportPatSaveBtn) { + githubImportPatSaveBtn.addEventListener("click", async function() { + const previousText = githubImportPatSaveBtn.textContent; + githubImportPatSaveBtn.disabled = true; + githubImportPatSaveBtn.textContent = "Adding…"; + try { + await saveGitHubAccess(); + } catch (error) { + showAppToast(error && error.message ? error.message : "Private access could not be saved.", { + tone: "error", + title: "GitHub access not added", + dedupeKey: "github-access-feedback" + }); + } finally { + githubImportPatSaveBtn.disabled = false; + githubImportPatSaveBtn.textContent = previousText; + } + }); + } + if (githubImportPatRemoveBtn) { + githubImportPatRemoveBtn.addEventListener("click", async function() { + githubImportPatRemoveBtn.disabled = true; + try { + await removeGitHubAccess(); + } catch (_) { + showAppToast("Private access could not be removed.", { + tone: "error", + title: "GitHub access not removed", + dedupeKey: "github-access-feedback" + }); + } finally { + githubImportPatRemoveBtn.disabled = false; + } + }); + } + const bindGitHubAccessEnterAction = function(input, actionButton) { + if (!input || !actionButton) return; + input.addEventListener("keydown", function(event) { + if (event.key === "Enter") { + event.preventDefault(); + actionButton.click(); + } + }); + }; + bindGitHubAccessEnterAction(githubImportPatInput, githubImportPatSaveBtn); + void loadStoredGitHubAccess(); const handleGitHubImportInputKeydown = function(e) { if (e.key === "Enter") { e.preventDefault(); @@ -17878,6 +18831,24 @@ ${selector} .arrowheadPath { if (githubImportFileSelect) { githubImportFileSelect.addEventListener("keydown", handleGitHubImportInputKeydown); } + if (githubImportSearchInput) { + githubImportSearchInput.addEventListener("input", function() { + clearTimeout(githubImportSearchTimer); + githubImportSearchTimer = setTimeout(function() { + renderGitHubImportTree(); + const visibleCount = getFilteredGitHubImportPaths().length; + announceToScreenReader(visibleCount + " Markdown file" + (visibleCount === 1 ? "" : "s") + " shown."); + }, 120); + }); + githubImportSearchInput.addEventListener("keydown", function(e) { + if (e.key === "Escape" && githubImportSearchInput.value) { + e.preventDefault(); + e.stopPropagation(); + githubImportSearchInput.value = ""; + renderGitHubImportTree(); + } + }); + } if (githubImportSelectAllBtn) { githubImportSelectAllBtn.addEventListener("click", function() { const allPaths = availableGitHubImportPaths.slice(); @@ -17885,6 +18856,9 @@ ${selector} .arrowheadPath { setGitHubSelectedPaths(shouldSelectAll ? allPaths : []); }); } + if (githubImportToggleFoldersBtn) { + githubImportToggleFoldersBtn.addEventListener("click", toggleAllGitHubImportFolders); + } fileInput.addEventListener("change", async function (e) { try { diff --git a/desktop-app/resources/js/workspace-storage.js b/desktop-app/resources/js/workspace-storage.js index b7d9b185..20cde308 100644 --- a/desktop-app/resources/js/workspace-storage.js +++ b/desktop-app/resources/js/workspace-storage.js @@ -817,6 +817,31 @@ await transactionToPromise(transaction); } + async deleteMetadata(key) { + if (this.desktop) { + if (key === 'vaultSettings') { + this.desktopSettings = {}; + const settingsPath = await this._pathJoin(this.vaultPath, INTERNAL_DIR, 'settings.json'); + if (await this._pathExists(settingsPath)) await Neutralino.filesystem.remove(settingsPath); + return; + } + if (key === 'secretManifest') { + const manifestPath = await this._pathJoin(this.vaultPath, INTERNAL_DIR, 'secret-manifest.json'); + if (await this._pathExists(manifestPath)) await Neutralino.filesystem.remove(manifestPath); + return; + } + if (Neutralino.storage.removeData) { + try { + await Neutralino.storage.removeData('markdownViewerMeta_' + key); + } catch (_) {} + } + return; + } + const transaction = this.db.transaction('metadata', 'readwrite'); + transaction.objectStore('metadata').delete(key); + await transactionToPromise(transaction); + } + async getSecretManifest() { const stored = await this.getMetadata('secretManifest'); if (stored) return stored; diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css index 52f2e28c..f42bc59f 100644 --- a/desktop-app/resources/styles.css +++ b/desktop-app/resources/styles.css @@ -5945,111 +5945,656 @@ a:focus { #github-import-modal .reset-modal-box { width: min(92vw, 520px); max-width: 520px; + max-height: calc(100dvh - 40px); min-width: 0; padding: 0; gap: 0; overflow: hidden; } -#github-import-modal .reset-modal-message { - font-size: var(--ui-font-lg); - line-height: 1.3; - text-align: left; +#github-import-modal.is-selection-step .reset-modal-box { + width: min(94vw, 760px); + max-width: 760px; +} + +#github-import-modal .reset-modal-message { + font-size: var(--ui-font-lg); + line-height: 1.3; + text-align: left; +} + +#github-import-modal .modal-header { + padding: 14px 16px; + border-bottom: 1px solid var(--border-color); +} + +.github-import-modal-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 10px; + min-height: 0; + max-height: none; + overflow-x: hidden; + overflow-y: auto; + padding: 16px; + overscroll-behavior: contain; + scrollbar-color: color-mix(in srgb, var(--text-secondary) 45%, transparent) transparent; + scrollbar-gutter: stable; + scrollbar-width: thin; +} + +.github-import-modal-body::-webkit-scrollbar { + width: 8px; +} + +.github-import-modal-body::-webkit-scrollbar-track { + background: transparent; +} + +.github-import-modal-body::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: 999px; + background: color-mix(in srgb, var(--text-secondary) 45%, transparent); + background-clip: padding-box; +} + +#github-import-modal.is-selection-step .github-import-modal-body { + overflow: hidden; +} + +#github-import-url, +#github-import-file-select { + min-height: 36px; + padding: 7px 10px; + font-size: var(--ui-font-md); +} + +#github-import-file-select { + min-height: 180px; +} + +.github-import-access[hidden], +.github-import-access-panel[hidden], +.github-import-access-manage[hidden], +.github-import-access-form[hidden], +.github-import-remove-token-btn[hidden], +.github-import-repository-context[hidden], +.github-import-ref[hidden], +.github-import-base-path[hidden] { + display: none !important; +} + +.github-import-access { + flex: 0 0 auto; + overflow: hidden; + border: 1px solid var(--border-color); + border-radius: 9px; + background: var(--bg-color); +} + +.github-import-access-toggle { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 48px; + padding: 8px 10px; + border: 0; + background: transparent; + color: var(--text-color); + cursor: pointer; + text-align: left; + font: inherit; +} + +.github-import-access-toggle:hover, +.github-import-access-toggle:focus-visible { + background: var(--button-hover); +} + +.github-import-access-toggle:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: -2px; +} + +.github-import-access-icon { + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + flex: 0 0 auto; + border-radius: 8px; + background: color-mix(in srgb, var(--text-secondary) 10%, transparent); + color: var(--text-secondary); +} + +.github-import-access-icon > .lucide { + width: 17px; + height: 17px; +} + +.github-import-access[data-state="active"] .github-import-access-icon { + background: color-mix(in srgb, #1a7f37 13%, transparent); + color: #1a7f37; +} + +.github-import-access-copy { + display: grid; + gap: 2px; + min-width: 0; + flex: 1; +} + +.github-import-access-title { + font-size: var(--ui-font-md); + font-weight: 700; +} + +.github-import-access-summary { + overflow: hidden; + color: var(--text-secondary); + font-size: var(--ui-font-sm); + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.github-import-access-chevron { + width: 16px; + height: 16px; + flex: 0 0 auto; + color: var(--text-secondary); + transition: transform 160ms ease; +} + +.github-import-access-toggle[aria-expanded="true"] .github-import-access-chevron { + transform: rotate(180deg); +} + +.github-import-access-panel { + display: grid; + gap: 10px; + padding: 12px; + border-top: 1px solid var(--border-color); + background: var(--bg-color); +} + +.github-import-access-form-heading, +.github-import-access-manage-header, +.github-import-access-form-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.github-import-access-form-heading { + font-size: var(--ui-font-md); +} + +.github-import-access-field { + display: grid; + gap: 6px; + min-width: 0; +} + +.github-import-access-form, +.github-import-access-manage { + display: grid; + gap: 10px; +} + +.github-import-access-form { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.github-import-access-form-heading, +.github-import-access-token-field, +.github-import-access-form-footer { + grid-column: 1 / -1; +} + +.github-import-access-form .reset-modal-label, +.github-import-access-manage .reset-modal-label, +.github-import-access-form-footer > button { + white-space: nowrap; +} + +.github-import-access-selected-meta { + display: block; + min-width: 0; + overflow: hidden; + color: var(--text-secondary); + font-size: var(--ui-font-sm); + text-overflow: ellipsis; + white-space: nowrap; +} + +.github-import-access-panel .rename-modal-input { + width: 100%; + min-height: 38px; + padding: 7px 9px; + font-size: var(--ui-font-md); +} + +.github-import-toolbar-actions { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + gap: 4px; +} + +.github-import-toolbar-btn.tool-button { + width: 30px; + height: 30px; + min-width: 30px; + padding: 0; + border-color: transparent; + border-radius: 7px; + background: transparent; +} + +.github-import-toolbar-btn.tool-button:hover, +.github-import-toolbar-btn.tool-button:focus-visible { + border-color: transparent; + background: var(--toolbar-control-hover); +} + +.github-import-toolbar-btn > .lucide { + width: 16px; + height: 16px; +} + +.github-import-remove-token-btn { + color: var(--color-danger-fg, #cf222e); +} + +.github-import-access-form-footer { + padding-top: 2px; +} + +.github-import-access-session-note { + min-width: 0; + overflow: hidden; + color: var(--text-secondary); + font-size: var(--ui-font-sm); + text-overflow: ellipsis; + white-space: nowrap; +} + +.github-import-optional-label { + color: var(--text-secondary); + font-size: var(--ui-font-sm); + font-weight: 400; +} + +.github-import-access-form-footer .reset-modal-btn { + min-height: 34px; + padding: 7px 14px; + font-size: var(--ui-font-md); +} + +.github-import-repository-context { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + padding: 9px 10px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: color-mix(in srgb, var(--button-bg) 62%, var(--bg-color)); +} + +.github-import-repository-icon { + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + flex: 0 0 auto; + border-radius: 8px; + background: var(--bg-color); + color: var(--text-color); +} + +.github-import-repository-icon > .lucide, +.github-import-repository-icon > .bi { + width: 18px; + height: 18px; + font-size: 18px; + line-height: 1; +} + +.github-import-repository-copy { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + flex: 1 1 auto; + overflow: hidden; +} + +.github-import-repository-heading, +.github-import-repository-meta, +.github-import-ref, +.github-import-commit-link { + display: flex; + align-items: center; +} + +.github-import-repository-heading { + flex: 1 1 auto; + gap: 8px; + min-width: 0; +} + +.github-import-repository-heading strong { + overflow: hidden; + color: var(--text-color); + font-size: var(--ui-font-lg); + text-overflow: ellipsis; + white-space: nowrap; +} + +.github-import-commit-link { + gap: 4px; + flex: 0 0 auto; + padding: 2px 7px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-color); + color: var(--accent-color); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: var(--ui-font-sm); + font-weight: 650; + text-decoration: none; +} + +.github-import-commit-link > .lucide, +.github-import-ref > .lucide { + width: 13px; + height: 13px; +} + +.github-import-commit-link:hover { + border-color: var(--accent-color); +} + +.github-import-commit-link:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: 2px; +} + +.github-import-repository-meta { + flex: 0 1 auto; + gap: 7px; + min-width: 0; + color: var(--text-secondary); + font-size: var(--ui-font-sm); + white-space: nowrap; +} + +.github-import-ref { + flex: 0 1 auto; + gap: 4px; + min-width: 0; +} + +.github-import-ref > span, +.github-import-base-path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.github-import-tree { + min-height: 240px; + max-height: min(48vh, 440px); + overflow: auto; + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 0; + background: var(--bg-color); + overscroll-behavior: contain; + scrollbar-gutter: stable; } -#github-import-modal .modal-header { - padding: 14px 16px; - border-bottom: 1px solid var(--border-color); +.github-import-selection-toolbar { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto; + align-items: center; + gap: 8px; } -.github-import-modal-body { +.github-import-search { + position: relative; display: flex; - flex-direction: column; - gap: 8px; - max-height: min(56vh, 460px); - overflow: auto; - padding: 16px; + align-items: center; + min-width: 0; + margin: 0; } -#github-import-url, -#github-import-file-select { - min-height: 36px; - padding: 7px 10px; +.github-import-search > .lucide { + position: absolute; + left: 11px; + z-index: 1; + width: 16px; + height: 16px; + color: var(--text-secondary); + pointer-events: none; +} + +#github-import-search { + width: 100%; + min-width: 0; + min-height: 40px; + padding: 8px 12px 8px 36px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-color); + color: var(--text-color); + font: inherit; font-size: var(--ui-font-md); } -#github-import-file-select { - min-height: 180px; +#github-import-search::placeholder { + color: var(--text-secondary); } -.github-import-tree { - max-height: 320px; - overflow: auto; - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 12px; - background: var(--bg-color); +#github-import-search:focus-visible { + border-color: var(--accent-color); + outline: 2px solid color-mix(in srgb, var(--accent-color) 24%, transparent); + outline-offset: 1px; } -.github-import-selection-toolbar { - display: flex; +.github-import-selection-actions { + display: inline-flex; align-items: center; - justify-content: space-between; + justify-content: flex-end; gap: 8px; - padding: 8px 10px; - border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--button-bg); + min-width: max-content; } .github-import-selected-count { - font-size: var(--ui-font-md); - font-weight: 600; + display: inline-flex; + align-items: center; + min-height: 30px; + margin: 0; + padding: 0 9px; + border-radius: 7px; + background: var(--toolbar-control-hover); color: var(--text-color); + font-size: var(--ui-font-sm); + font-weight: 600; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.github-import-toolbar-btn[aria-pressed="true"] { + border-color: transparent; + color: var(--accent-color); + background-color: rgba(3, 102, 214, 0.08); } .github-import-tree ul { list-style: none; margin: 0; - padding-left: 18px; + padding-left: 24px; +} + +.github-import-tree ul ul { + margin-left: 16px; + padding-left: 8px; + border-left: 1px solid color-mix(in srgb, var(--border-color) 76%, transparent); } .github-import-tree>ul { - padding-left: 4px; + padding-left: 0; +} + +.github-import-tree>ul>li:last-child>.github-tree-file-btn, +.github-import-tree>ul>li:last-child>.github-tree-folder-label { + border-bottom-color: transparent; } .github-import-tree li { - margin: 2px 0; + margin: 0; } .github-tree-folder-label { - display: inline-block; - font-size: 14px; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 40px; + padding: 7px 10px; + border: 0; + border-bottom: 1px solid var(--border-color); + background: transparent; + color: var(--text-color); + cursor: pointer; + text-align: left; + font: inherit; + font-size: var(--ui-font-md); +} + +.github-tree-folder-label:hover, +.github-tree-folder-label:focus-visible { + background: var(--button-hover); +} + +.github-tree-folder-label:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: -2px; +} + +.github-tree-chevron { + width: 14px; + height: 14px; color: var(--text-secondary, #57606a); - margin-bottom: 4px; + flex: 0 0 auto; +} + +.github-tree-folder-icon { + width: 17px; + height: 17px; + color: var(--accent-color); + flex: 0 0 auto; +} + +.github-tree-folder-count { + min-width: 24px; + margin-left: auto; + padding: 1px 7px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--button-bg); + color: var(--text-secondary); + font-size: 11px; + line-height: 1.45; + text-align: center; + font-variant-numeric: tabular-nums; } .github-tree-file-btn { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 40px; + padding: 7px 10px 7px 32px; border: 0; + border-bottom: 1px solid var(--border-color); background: transparent; color: var(--text-color); cursor: pointer; - padding: 6px 8px; - border-radius: 6px; text-align: left; - width: 100%; - font-size: 14px; + font: inherit; + font-size: var(--ui-font-md); } .github-tree-file-btn:hover, .github-tree-file-btn:focus-visible { background: var(--button-hover); - outline: none; +} + +.github-tree-file-btn:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: -2px; } .github-tree-file-btn.is-selected { - background: rgba(56, 139, 253, 0.14); + background: color-mix(in srgb, var(--accent-color) 12%, transparent); + color: var(--accent-color); +} + +.github-tree-file-icon { + width: 16px; + height: 16px; + color: var(--text-secondary); + flex: 0 0 auto; +} + +.github-tree-file-btn.is-selected .github-tree-file-icon { + color: var(--accent-color); +} + +.github-import-tree-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.github-tree-selection-icon { + width: 16px; + height: 16px; + margin-left: auto; color: var(--accent-color); + flex: 0 0 auto; +} + +.github-tree-selection-icon[hidden] { + display: none; +} + +.github-import-tree-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 220px; + padding: 24px; + color: var(--text-secondary); + text-align: center; + font-size: var(--ui-font-md); +} + +.github-import-tree-empty > .lucide { + width: 24px; + height: 24px; } #github-import-modal .reset-modal-actions { @@ -6074,8 +6619,45 @@ a:focus { } .github-import-selection-toolbar { - flex-direction: column; - align-items: stretch; + grid-template-columns: minmax(0, 1fr) auto; + } + + .github-import-search { + grid-column: 1 / -1; + } + + #github-import-search, + .github-import-toolbar-btn.tool-button { + min-height: 44px; + } + + .github-import-toolbar-btn.tool-button { + width: 44px; + height: 44px; + min-width: 44px; + } + + .github-import-selection-actions { + justify-content: space-between; + } + + .github-import-selected-count { + min-height: 44px; + padding-inline: 10px; + } + + .github-import-tree { + min-height: 220px; + max-height: min(46vh, 380px); + } + + .github-tree-folder-label, + .github-tree-file-btn { + min-height: 44px; + } + + .github-import-access-panel .rename-modal-input { + min-height: 44px; } #github-import-modal .reset-modal-message { @@ -6092,6 +6674,34 @@ a:focus { } } +@media (max-width: 420px) { + .github-import-access-form { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (prefers-reduced-motion: reduce) { + .github-import-toolbar-btn, + .github-import-access-chevron { + transition: none; + } +} + +@media (max-width: 900px) and (max-height: 500px) { + #github-import-search, + .github-import-toolbar-btn.tool-button, + .github-tree-folder-label, + .github-tree-file-btn { + min-height: 44px; + } + + .github-import-toolbar-btn.tool-button { + width: 44px; + height: 44px; + min-width: 44px; + } +} + .frontmatter-table { border-collapse: collapse; margin-bottom: 1.5em; @@ -6207,6 +6817,8 @@ a:focus { [dir="rtl"] .alert-option, [dir="rtl"] .github-import-error, [dir="rtl"] #github-import-modal .reset-modal-message, +[dir="rtl"] .github-import-access-toggle, +[dir="rtl"] .github-import-access-panel, [dir="rtl"] .github-tree-file-btn, [dir="rtl"] .frontmatter-table td { text-align: right; @@ -6214,11 +6826,41 @@ a:focus { [dir="rtl"] .github-import-tree ul { padding-left: 0; - padding-right: 18px; + padding-right: 24px; } [dir="rtl"] .github-import-tree>ul { - padding-right: 4px; + padding-right: 0; +} + +[dir="rtl"] .github-import-tree ul ul { + margin-right: 16px; + margin-left: 0; + padding-right: 8px; + padding-left: 0; + border-right: 1px solid color-mix(in srgb, var(--border-color) 76%, transparent); + border-left: 0; +} + +[dir="rtl"] .github-import-search > .lucide { + right: 11px; + left: auto; +} + +[dir="rtl"] #github-import-search { + padding-right: 36px; + padding-left: 12px; +} + +[dir="rtl"] .github-tree-file-btn { + padding-right: 32px; + padding-left: 10px; +} + +[dir="rtl"] .github-tree-folder-count, +[dir="rtl"] .github-tree-selection-icon { + margin-right: auto; + margin-left: 0; } [dir="rtl"] .markdown-body .markdown-alert, diff --git a/index.html b/index.html index 23e3e69d..c14864d6 100644 --- a/index.html +++ b/index.html @@ -1374,19 +1374,99 @@

Markdown Viewer

diff --git a/script.js b/script.js index daf88105..5651c042 100644 --- a/script.js +++ b/script.js @@ -534,11 +534,36 @@ document.addEventListener("DOMContentLoaded", async function () { const mobileLiveShareParticipants = document.getElementById("mobile-live-share-participants"); const githubImportModal = document.getElementById("github-import-modal"); const githubImportTitle = document.getElementById("github-import-title"); + const githubImportSubtitle = document.getElementById("github-import-subtitle"); const githubImportUrlInput = document.getElementById("github-import-url"); const githubImportUrlLabel = document.getElementById("github-import-url-label"); + const githubImportAccess = document.getElementById("github-import-access"); + const githubImportAccessToggle = document.getElementById("github-import-access-toggle"); + const githubImportAccessPanel = document.getElementById("github-import-access-panel"); + const githubImportAccessSummary = document.getElementById("github-import-access-summary"); + const githubImportAccessManage = document.getElementById("github-import-access-manage"); + const githubImportAccessForm = document.getElementById("github-import-access-form"); + const githubImportPatSelect = document.getElementById("github-import-pat-select"); + const githubImportAccessSelectedMeta = document.getElementById("github-import-access-selected-meta"); + const githubImportPatAddAnotherBtn = document.getElementById("github-import-pat-add-another"); + const githubImportPatAddCancelBtn = document.getElementById("github-import-pat-add-cancel"); + const githubImportPatNameInput = document.getElementById("github-import-pat-name"); + const githubImportPatExpiryInput = document.getElementById("github-import-pat-expiry"); + const githubImportPatInput = document.getElementById("github-import-pat"); + const githubImportPatSaveBtn = document.getElementById("github-import-pat-save"); + const githubImportPatRemoveBtn = document.getElementById("github-import-pat-remove"); const githubImportFileSelect = document.getElementById("github-import-file-select"); + const githubImportRepositoryContext = document.getElementById("github-import-repository-context"); + const githubImportRepositoryName = document.getElementById("github-import-repository-name"); + const githubImportCommitLink = document.getElementById("github-import-commit-link"); + const githubImportCommit = document.getElementById("github-import-commit"); + const githubImportRefItem = document.getElementById("github-import-ref-item"); + const githubImportRef = document.getElementById("github-import-ref"); + const githubImportBasePath = document.getElementById("github-import-base-path"); const githubImportSelectionToolbar = document.getElementById("github-import-selection-toolbar"); + const githubImportSearchInput = document.getElementById("github-import-search"); const githubImportSelectedCount = document.getElementById("github-import-selected-count"); + const githubImportToggleFoldersBtn = document.getElementById("github-import-toggle-folders"); const githubImportSelectAllBtn = document.getElementById("github-import-select-all"); const githubImportTree = document.getElementById("github-import-tree"); const githubImportError = document.getElementById("github-import-error"); @@ -7746,9 +7771,74 @@ document.addEventListener("DOMContentLoaded", async function () { idsToClose = openTabs.map(function(tab) { return tab.id; }); } closeTabMenus(); - idsToClose.forEach(function(id) { - if (tabs.some(function(tab) { return tab.id === id && isTabOpen(tab); })) closeTab(id); + if (idsToClose.length === 1) { + closeTab(idsToClose[0]); + return; + } + closeTabsInBatch(idsToClose); + } + + function closeTabsInBatch(tabIds) { + const idsToClose = new Set((tabIds || []).filter(Boolean)); + if (!idsToClose.size) return; + + // Live sessions have teardown and restore behavior that must keep using the + // single-tab path. All ordinary documents can be closed in one state update. + if (liveCollaboration && idsToClose.has(liveCollaboration.tabId)) { + Array.from(idsToClose).forEach(function(id) { + if (tabs.some(function(tab) { return tab.id === id && isTabOpen(tab); })) closeTab(id); + }); + return; + } + + const openTabsBeforeClose = getOpenTabs(); + const activeOpenIndex = openTabsBeforeClose.findIndex(function(tab) { return tab.id === activeTabId; }); + const activeWillClose = idsToClose.has(activeTabId); + const splitWillClose = idsToClose.has(secondarySplitTabId) || (activeWillClose && Boolean(secondarySplitTabId)); + const changedIds = []; + const temporaryIds = new Set(); + + if (pendingReviewDelete && idsToClose.has(pendingReviewDelete.tabId)) { + cancelReviewDeleteConfirmation(); + } + if (activeWillClose) saveCurrentTabState(); + saveSecondarySplitState(); + if (splitWillClose) closeDocumentSplitView({ silent: true, renderTabs: false }); + + tabs.forEach(function(tab) { + if (!idsToClose.has(tab.id) || !isTabOpen(tab)) return; + if (isTemporaryDocument(tab)) { + temporaryIds.add(tab.id); + shareSnapshotViewOnlyTabIds.delete(tab.id); + if (tabHistories[tab.id]) delete tabHistories[tab.id]; + return; + } + tab.isOpen = false; + changedIds.push(tab.id); }); + + if (temporaryIds.size) { + tabs = tabs.filter(function(tab) { return !temporaryIds.has(tab.id); }); + } + if (changedIds.length) saveTabsToStorage(tabs, changedIds); + + if (activeWillClose) { + const remainingOpenTabs = getOpenTabs(); + const nextTab = remainingOpenTabs.length + ? remainingOpenTabs[Math.min(Math.max(activeOpenIndex - 1, 0), remainingOpenTabs.length - 1)] + : null; + if (nextTab) { + void switchTab(nextTab.id).then(renderDocumentSidebar, renderDocumentSidebar); + } else { + clearActiveDocument(); + renderDocumentSidebar(); + } + } else { + renderDocumentSplitView(); + renderTabBar(tabs, activeTabId); + renderDocumentSidebar(); + } + announceToScreenReader(idsToClose.size + ' tabs closed. Files remain available in Explorer.'); } function openTabContextMenu(tab, point, focusTab) { @@ -11105,16 +11195,37 @@ ${selector} .arrowheadPath { mediaProgressHideTimeout = setTimeout(function() { toast.hidden = true; }, 4200); } + function createImportedDocument(content, title, location) { + const targetLocation = location || (documentOrganization + ? getPreferredDocumentLocation() + : { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }); + if (targetLocation.workspaceId === SECRET_WORKSPACE_ID && !isSecretWorkspaceUnlocked()) return null; + const tab = createTab(content, title, 'split', targetLocation); + tab.isOpen = false; + normalizeTabDocumentMetadata(tab, { allowSecret: targetLocation.workspaceId === SECRET_WORKSPACE_ID }); + tabs.push(tab); + return tab; + } + + async function persistImportedDocuments(documentIds) { + const ids = Array.from(documentIds || []).filter(Boolean); + if (!ids.length) return; + saveTabsToStorage(tabs, ids); + await _flushTabsToStorage(tabs, { changedIds: ids }); + evictInactiveDocumentContent(); + renderDocumentSidebar(); + } + function importMarkdownFile(file, location) { return new Promise(function(resolve) { if (!file) { - resolve(false); + resolve(null); return; } if (file.size > 10 * 1024 * 1024) { showUnsupportedFileToast('This Markdown file is too large. The maximum supported size is 10 MB.'); - resolve(false); + resolve(null); return; } @@ -11127,16 +11238,16 @@ ${selector} .arrowheadPath { for (let i = 0; i < checkLength; i++) { if (text.charCodeAt(i) === 0) { showUnsupportedFileToast('This file appears to be binary. Choose a Markdown file (.md or .markdown).'); - resolve(false); + resolve(null); return; } } - resolve(newTab(text, getMarkdownFileTitle(file), location) === true); + resolve(createImportedDocument(text, getMarkdownFileTitle(file), location)); }; reader.onerror = function() { alert('Failed to read the file. Please check permissions and try again.'); - resolve(false); + resolve(null); }; reader.readAsText(file); }); @@ -11160,17 +11271,25 @@ ${selector} .arrowheadPath { const filesToImport = markdownFiles; let importedCount = 0; + const importedDocumentIds = []; showImportProgress(filesToImport.length); for (let index = 0; index < filesToImport.length; index++) { - if (await importMarkdownFile(filesToImport[index], settings.location)) { + const importedDocument = await importMarkdownFile(filesToImport[index], settings.location); + if (importedDocument) { importedCount++; + importedDocumentIds.push(importedDocument.id); } updateImportProgress(index + 1, filesToImport.length); } + await persistImportedDocuments(importedDocumentIds); if (settings.location && settings.location.workspaceId === SECRET_WORKSPACE_ID && isSecretWorkspaceUnlocked()) { await flushSecretWorkspaceToStorage(); } - finishImportProgress(importedCount, filesToImport.length); + finishImportProgress(importedCount, filesToImport.length, { + detail: importedCount === filesToImport.length + ? 'Saved to Explorer without opening new tabs.' + : undefined + }); return importedCount; } @@ -11178,11 +11297,22 @@ ${selector} .arrowheadPath { function isMarkdownPath(path) { return /\.(md|markdown)$/i.test(path || ""); } - const MAX_GITHUB_FILES_SHOWN = 30; const GITHUB_IMPORT_MIN_REQUEST_INTERVAL_MS = 800; + const GITHUB_API_VERSION = "2022-11-28"; + const GITHUB_ACCESS_VAULT_NAME = "githubAccessVaultV2"; + const GITHUB_LEGACY_ACCESS_RECORD_NAME = "githubAccessRecordV1"; let lastGitHubImportRequestAt = 0; const selectedGitHubImportPaths = new Set(); + const collapsedGitHubImportFolders = new Set(); let availableGitHubImportPaths = []; + let githubImportSearchTimer = null; + let githubAccessEntries = []; + let githubSelectedAccessId = ""; + let githubAccessAdding = true; + let githubAccessVaultLoaded = false; + let githubAccessVaultLoadPromise = null; + const githubSessionAccessTokens = new Map(); + let githubAccessExpiryTimer = null; function getFileName(path) { return (path || "").split("/").pop() || "document.md"; @@ -11206,11 +11336,37 @@ ${selector} .arrowheadPath { return parentFolder || repositoryFolder; } - async function importGitHubFilesInBackground(owner, repo, ref, filePaths) { + function encodeGitHubPath(path) { + return String(path || "") + .split("/") + .filter(Boolean) + .map((part) => encodeURIComponent(part)) + .join("/"); + } + + function getGitHubRepositoryFolderName(context) { + if (!context || !context.repo) return "GitHub import"; + return context.isDefaultRef || !context.commitSha + ? context.repo + : `${context.repo} @ ${context.commitSha.slice(0, 7)}`; + } + + async function fetchGitHubFileContent(context, filePath) { + if (context.isPrivate) { + const url = `https://api.github.com/repos/${encodeURIComponent(context.owner)}/${encodeURIComponent(context.repo)}/contents/${encodeGitHubPath(filePath)}?ref=${encodeURIComponent(context.commitSha)}`; + return fetchGitHubText(url, { + authenticated: true, + accept: "application/vnd.github.raw+json" + }); + } + return fetchTextContent(buildRawGitHubUrl(context.owner, context.repo, context.commitSha, filePath)); + } + + async function importGitHubFilesInBackground(context, filePaths) { const paths = Array.from(filePaths || []).filter(Boolean); if (!paths.length) return 0; - const folder = ensureWorkspaceFolder(DEFAULT_WORKSPACE_ID, repo); + const folder = ensureWorkspaceFolder(DEFAULT_WORKSPACE_ID, getGitHubRepositoryFolderName(context)); if (!folder) { showImportProgress(paths.length, { title: 'GitHub import', detail: 'Preparing repository folder…' }); finishImportProgress(0, paths.length, { detail: 'The repository folder could not be created.' }); @@ -11224,15 +11380,22 @@ ${selector} .arrowheadPath { }); let imported = 0; + const importedDocumentIds = []; for (let index = 0; index < paths.length; index++) { const filePath = paths[index]; updateImportProgress(index, paths.length, 'Importing ' + getFileName(filePath)); try { - const markdown = await fetchTextContent(buildRawGitHubUrl(owner, repo, ref, filePath)); + const markdown = await fetchGitHubFileContent(context, filePath); const destinationFolder = ensureGitHubFolderPath(DEFAULT_WORKSPACE_ID, folder, filePath); const destination = { workspaceId: DEFAULT_WORKSPACE_ID, folderId: destinationFolder.id }; - if (newTab(markdown, getFileName(filePath).replace(/\.(md|markdown)$/i, ''), destination)) { + const importedDocument = createImportedDocument( + markdown, + getFileName(filePath).replace(/\.(md|markdown)$/i, ''), + destination + ); + if (importedDocument) { imported++; + importedDocumentIds.push(importedDocument.id); } } catch (error) { console.error('GitHub file import failed:', filePath, error); @@ -11240,11 +11403,12 @@ ${selector} .arrowheadPath { updateImportProgress(index + 1, paths.length, 'Saving to Workspace / ' + folder.name); } + await persistImportedDocuments(importedDocumentIds); const failureCount = paths.length - imported; finishImportProgress(imported, paths.length, { detail: failureCount ? failureCount + ' file' + (failureCount === 1 ? '' : 's') + ' could not be imported.' - : 'Saved in Workspace / ' + folder.name + : 'Saved in Workspace / ' + folder.name + ' without opening new tabs.' }); announceToScreenReader(imported === paths.length ? 'GitHub import complete. Files saved in the ' + folder.name + ' folder.' @@ -11252,11 +11416,11 @@ ${selector} .arrowheadPath { return imported; } - function startGitHubImport(owner, repo, ref, filePaths) { + function startGitHubImport(context, filePaths) { const paths = Array.from(filePaths || []).filter(Boolean); closeGitHubImportModal(); if (!paths.length) return; - void importGitHubFilesInBackground(owner, repo, ref, paths).catch(function(error) { + void importGitHubFilesInBackground(context, paths).catch(function(error) { console.error('GitHub import failed:', error); showImportProgress(paths.length, { title: 'GitHub import', detail: 'Unable to start import.' }); finishImportProgress(0, paths.length, { detail: error && error.message ? error.message : 'GitHub import failed.' }); @@ -11264,7 +11428,53 @@ ${selector} .arrowheadPath { }); } - async function fetchGitHubJson(url) { + class GitHubRequestError extends Error { + constructor(status, message) { + super(message || `GitHub API request failed (${status})`); + this.name = "GitHubRequestError"; + this.status = status; + } + } + + function createGitHubAccessId() { + if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") { + return "github_access_" + globalThis.crypto.randomUUID(); + } + return "github_access_" + Date.now() + "_" + Math.random().toString(36).slice(2, 10); + } + + function getSelectedGitHubAccessEntry() { + return githubAccessEntries.find(function(entry) { + return entry.id === githubSelectedAccessId; + }) || null; + } + + function getGitHubAccessToken() { + const expiredEntries = pruneExpiredGitHubAccessEntries(); + if (expiredEntries.length) renderGitHubAccessState(); + return githubSessionAccessTokens.get(githubSelectedAccessId) || ""; + } + + function getGitHubApiHeaders(accept, authenticated) { + const headers = { + Accept: accept || "application/vnd.github+json", + "X-GitHub-Api-Version": GITHUB_API_VERSION + }; + if (authenticated) { + const token = getGitHubAccessToken(); + if (!token) { + throw new Error("The selected GitHub access token is unavailable or expired. Add it again."); + } + headers.Authorization = `Bearer ${token}`; + } + return headers; + } + + async function fetchGitHubResponse(url, options = {}) { + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== "https:" || parsedUrl.origin !== "https://api.github.com") { + throw new Error("Blocked an unsafe GitHub API destination."); + } const now = Date.now(); const waitTime = GITHUB_IMPORT_MIN_REQUEST_INTERVAL_MS - (now - lastGitHubImportRequestAt); if (waitTime > 0) { @@ -11272,24 +11482,55 @@ ${selector} .arrowheadPath { } lastGitHubImportRequestAt = Date.now(); const response = await fetch(url, { - headers: { - Accept: "application/vnd.github+json" - } + headers: getGitHubApiHeaders(options.accept, options.authenticated === true), + redirect: "error", + cache: "no-store" }); if (!response.ok) { - throw new Error(`GitHub API request failed (${response.status})`); + let message = `GitHub API request failed (${response.status})`; + if (response.status === 401) { + message = "GitHub rejected the token. Check that it is active and has read-only repository Contents access."; + } else if (response.status === 403) { + message = "GitHub denied this request. Check token permissions, organization approval, or API rate limits."; + } + throw new GitHubRequestError(response.status, message); } + return response; + } + + async function fetchGitHubJson(url, options = {}) { + const response = await fetchGitHubResponse(url, options); return response.json(); } + async function fetchGitHubText(url, options = {}) { + const response = await fetchGitHubResponse(url, options); + return response.text(); + } + async function fetchTextContent(url) { - const response = await fetch(url); + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== "https:" || parsedUrl.origin !== "https://raw.githubusercontent.com") { + throw new Error("Blocked an unsafe GitHub content destination."); + } + const response = await fetch(url, { + redirect: "error", + cache: "no-store" + }); if (!response.ok) { throw new Error(`Failed to fetch file (${response.status})`); } return response.text(); } + function decodeGitHubPathSegments(pathname) { + try { + return pathname.split("/").filter(Boolean).map((segment) => decodeURIComponent(segment)); + } catch (_) { + return null; + } + } + function parseGitHubImportUrl(input) { let parsedUrl; try { @@ -11298,14 +11539,22 @@ ${selector} .arrowheadPath { return null; } + if (parsedUrl.protocol !== "https:" || parsedUrl.username || parsedUrl.password || parsedUrl.port) { + return null; + } const host = parsedUrl.hostname.replace(/^www\./, ""); - const segments = parsedUrl.pathname.split("/").filter(Boolean); + const segments = decodeGitHubPathSegments(parsedUrl.pathname); + if (!segments) return null; if (host === "raw.githubusercontent.com") { if (segments.length < 4) return null; - const [owner, repo, ref, ...rest] = segments; - const filePath = rest.join("/"); - return { owner, repo, ref, type: "file", filePath }; + return { + owner: segments[0], + repo: segments[1].replace(/\.git$/i, ""), + type: "file", + locationSegments: segments.slice(2), + source: "raw" + }; } if (host !== "github.com" || segments.length < 2) return null; @@ -11322,8 +11571,8 @@ ${selector} .arrowheadPath { owner, repo, type: "file", - ref: segments[3], - filePath: segments.slice(4).join("/") + locationSegments: segments.slice(3), + source: "blob" }; } @@ -11332,26 +11581,455 @@ ${selector} .arrowheadPath { owner, repo, type: "tree", - ref: segments[3], - basePath: segments.slice(4).join("/") + locationSegments: segments.slice(3), + source: "tree" }; } return { owner, repo, type: "repo" }; } - async function getDefaultBranch(owner, repo) { - const repoInfo = await fetchGitHubJson(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`); - return repoInfo.default_branch; + async function getGitHubRepositoryInfo(owner, repo) { + const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + try { + return { + info: await fetchGitHubJson(url), + authenticated: false + }; + } catch (error) { + const canRetryWithToken = error instanceof GitHubRequestError + && (error.status === 403 || error.status === 404) + && Boolean(getGitHubAccessToken()); + if (!canRetryWithToken) { + if (error instanceof GitHubRequestError && error.status === 404) { + const selectedAccess = getSelectedGitHubAccessEntry(); + if (selectedAccess && !githubSessionAccessTokens.has(selectedAccess.id)) { + throw new Error(`"${selectedAccess.name}" is no longer available. Add the token again.`); + } + throw new Error("Repository not found. If it is private, add secure private repository access and try again."); + } + throw error; + } + try { + return { + info: await fetchGitHubJson(url, { authenticated: true }), + authenticated: true + }; + } catch (authenticatedError) { + const selectedAccess = getSelectedGitHubAccessEntry(); + const accessName = selectedAccess ? `"${selectedAccess.name}"` : "The selected token"; + if (authenticatedError instanceof GitHubRequestError && authenticatedError.status === 401) { + throw new Error(`${accessName} was rejected by GitHub. Replace or remove it.`); + } + if (authenticatedError instanceof GitHubRequestError + && (authenticatedError.status === 403 || authenticatedError.status === 404)) { + throw new Error(`${accessName} cannot access ${owner}/${repo}. Check its repository access and permissions.`); + } + throw authenticatedError; + } + } + } + + async function resolveGitHubCommit(owner, repo, refName, authenticated) { + const commit = await fetchGitHubJson( + `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits/${encodeURIComponent(refName)}`, + { authenticated } + ); + if (!commit || !commit.sha) { + throw new Error("GitHub did not return a commit for this branch, tag, or commit reference."); + } + return commit; + } + + async function resolveGitHubLocation(parsed) { + const repository = await getGitHubRepositoryInfo(parsed.owner, parsed.repo); + const repoInfo = repository.info || {}; + const authenticated = Boolean(repository.authenticated || repoInfo.private); + const defaultBranch = repoInfo.default_branch || "main"; + let refName = defaultBranch; + let commit = null; + let remainder = []; + + if (parsed.type === "repo") { + commit = await resolveGitHubCommit(parsed.owner, parsed.repo, refName, authenticated); + } else { + const segments = Array.from(parsed.locationSegments || []).filter(Boolean); + const minimumRemainder = parsed.type === "file" ? 1 : 0; + const maximumRefSegments = segments.length - minimumRemainder; + for (let count = maximumRefSegments; count >= 1; count--) { + const candidate = segments.slice(0, count).join("/"); + try { + commit = await resolveGitHubCommit(parsed.owner, parsed.repo, candidate, authenticated); + refName = candidate; + remainder = segments.slice(count); + break; + } catch (error) { + if (!(error instanceof GitHubRequestError) || error.status !== 404) throw error; + } + } + if (!commit) { + throw new Error("No matching branch, tag, or commit was found in this GitHub URL."); + } + } + + return { + owner: parsed.owner, + repo: parsed.repo, + type: parsed.type, + refName, + commitSha: commit.sha, + defaultBranch, + isDefaultRef: refName === defaultBranch, + hasExplicitRef: parsed.type !== "repo", + isPrivate: Boolean(repoInfo.private), + authenticated, + filePath: parsed.type === "file" ? remainder.join("/") : "", + basePath: parsed.type === "tree" ? remainder.join("/") : "" + }; + } + + function toGitHubExpiryInputValue(timestamp) { + const date = new Date(timestamp); + const localDate = new Date(date.getTime() - (date.getTimezoneOffset() * 60000)); + return localDate.toISOString().slice(0, 16); + } + + function parseGitHubAccessExpiry(value) { + if (!value) return null; + const expiresAt = new Date(value).getTime(); + if (!Number.isFinite(expiresAt)) { + throw new Error("Enter a valid expiry date."); + } + if (expiresAt <= Date.now()) { + throw new Error("Choose an expiry date in the future."); + } + return expiresAt; + } + + function formatGitHubAccessExpiry(expiresAt) { + if (!expiresAt || !Number.isFinite(Number(expiresAt))) return "Available for this session"; + try { + return "Expires " + new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short" + }).format(new Date(expiresAt)); + } catch (_) { + return "Expires " + new Date(expiresAt).toLocaleString(); + } + } + + function pruneExpiredGitHubAccessEntries(options = {}) { + const expiredEntries = githubAccessEntries.filter(function(entry) { + return Number(entry.expiresAt) > 0 && Number(entry.expiresAt) <= Date.now(); + }); + if (!expiredEntries.length) return []; + const expiredIds = new Set(expiredEntries.map(function(entry) { return entry.id; })); + githubAccessEntries = githubAccessEntries.filter(function(entry) { + return !expiredIds.has(entry.id); + }); + expiredIds.forEach(function(id) { + githubSessionAccessTokens.delete(id); + }); + if (!githubAccessEntries.some(function(entry) { return entry.id === githubSelectedAccessId; })) { + githubSelectedAccessId = githubAccessEntries[0] ? githubAccessEntries[0].id : ""; + } + githubAccessAdding = !githubAccessEntries.length; + if (options.notify !== false) { + const message = expiredEntries.length === 1 + ? `"${expiredEntries[0].name}" expired and was removed.` + : `${expiredEntries.length} GitHub access tokens expired and were removed.`; + showAppToast(message, { + tone: "warning", + title: "GitHub access expired", + dedupeKey: "github-access-expired" + }); + } + return expiredEntries; + } + + function scheduleGitHubAccessExpiry() { + clearTimeout(githubAccessExpiryTimer); + githubAccessExpiryTimer = null; + const expiries = githubAccessEntries + .map(function(entry) { return Number(entry.expiresAt) || 0; }) + .filter(function(expiresAt) { return expiresAt > 0; }); + if (!expiries.length) return; + const delay = Math.max(0, Math.min(Math.min.apply(Math, expiries) - Date.now(), 2147483647)); + githubAccessExpiryTimer = setTimeout(function() { + const expiredEntries = pruneExpiredGitHubAccessEntries(); + if (expiredEntries.length) renderGitHubAccessState(); + else scheduleGitHubAccessExpiry(); + }, delay); + } + + function resetGitHubAccessForm() { + if (githubImportPatNameInput) githubImportPatNameInput.value = ""; + if (githubImportPatExpiryInput) { + githubImportPatExpiryInput.value = ""; + githubImportPatExpiryInput.min = toGitHubExpiryInputValue(Date.now() + 60000); + } + if (githubImportPatInput) githubImportPatInput.value = ""; } - async function listMarkdownFiles(owner, repo, ref, basePath) { - const treeResponse = await fetchGitHubJson(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(ref)}?recursive=1`); + function setGitHubAccessAdding(isAdding, options = {}) { + githubAccessAdding = Boolean(isAdding) || !githubAccessEntries.length; + if (githubAccessAdding && options.reset !== false) resetGitHubAccessForm(); + renderGitHubAccessState(); + if (githubAccessAdding && options.focus !== false && githubImportPatNameInput) { + requestAnimationFrame(function() { + githubImportPatNameInput.focus(); + githubImportPatNameInput.scrollIntoView({ block: "nearest" }); + }); + } + } + + function renderGitHubAccessState() { + pruneExpiredGitHubAccessEntries(); + githubAccessEntries = githubAccessEntries.filter(function(entry) { + return githubSessionAccessTokens.has(entry.id); + }); + if (!githubAccessEntries.some(function(entry) { return entry.id === githubSelectedAccessId; })) { + githubSelectedAccessId = githubAccessEntries[0] ? githubAccessEntries[0].id : ""; + } + + const selectedEntry = getSelectedGitHubAccessEntry(); + const hasEntries = githubAccessEntries.length > 0; + githubAccessAdding = githubAccessAdding || !hasEntries; + + if (githubImportAccess) { + githubImportAccess.dataset.state = selectedEntry ? "active" : "empty"; + } + if (githubImportAccessSummary) { + githubImportAccessSummary.textContent = selectedEntry + ? `${selectedEntry.name} · Ready` + : "No access token added"; + } + if (githubImportAccessManage) { + githubImportAccessManage.hidden = !hasEntries || githubAccessAdding; + } + if (githubImportAccessForm) { + githubImportAccessForm.hidden = hasEntries && !githubAccessAdding; + } + if (githubImportPatAddCancelBtn) { + githubImportPatAddCancelBtn.hidden = !hasEntries; + } + if (githubImportPatSelect) { + githubImportPatSelect.replaceChildren(); + githubAccessEntries.forEach(function(entry) { + const option = document.createElement("option"); + option.value = entry.id; + option.textContent = entry.name; + githubImportPatSelect.appendChild(option); + }); + githubImportPatSelect.value = githubSelectedAccessId; + } + if (githubImportAccessSelectedMeta) { + githubImportAccessSelectedMeta.textContent = selectedEntry + ? formatGitHubAccessExpiry(selectedEntry.expiresAt) + : ""; + } + if (githubImportPatRemoveBtn) { + githubImportPatRemoveBtn.hidden = !selectedEntry; + } + scheduleGitHubAccessExpiry(); + } + + async function loadStoredGitHubAccess() { + if (githubAccessVaultLoaded) { + renderGitHubAccessState(); + return; + } + if (githubAccessVaultLoadPromise) { + await githubAccessVaultLoadPromise; + return; + } + githubAccessVaultLoadPromise = (async function() { + if (!workspaceStorage) { + githubAccessVaultLoaded = true; + renderGitHubAccessState(); + return; + } + try { + await workspaceStorage.init(); + const vault = await workspaceStorage.getMetadata(GITHUB_ACCESS_VAULT_NAME); + const legacyRecord = await workspaceStorage.getMetadata(GITHUB_LEGACY_ACCESS_RECORD_NAME); + if (vault || legacyRecord) { + if (typeof workspaceStorage.deleteMetadata === "function") { + await workspaceStorage.deleteMetadata(GITHUB_ACCESS_VAULT_NAME); + await workspaceStorage.deleteMetadata(GITHUB_LEGACY_ACCESS_RECORD_NAME); + } else { + await workspaceStorage.setMetadata(GITHUB_ACCESS_VAULT_NAME, null); + await workspaceStorage.setMetadata(GITHUB_LEGACY_ACCESS_RECORD_NAME, null); + } + } + githubAccessVaultLoaded = true; + renderGitHubAccessState(); + } catch (_) { + githubAccessVaultLoaded = true; + showAppToast("Old saved GitHub access could not be cleared.", { + tone: "error", + title: "GitHub access cleanup failed", + dedupeKey: "github-access-cleanup-failed" + }); + renderGitHubAccessState(); + } + })(); + try { + await githubAccessVaultLoadPromise; + } finally { + githubAccessVaultLoadPromise = null; + } + } + + async function removeGitHubAccess(options = {}) { + const accessId = options.id || githubSelectedAccessId; + const removedEntry = githubAccessEntries.find(function(entry) { return entry.id === accessId; }); + if (!removedEntry) return; + githubAccessEntries = githubAccessEntries.filter(function(entry) { return entry.id !== accessId; }); + githubSessionAccessTokens.delete(accessId); + githubSelectedAccessId = githubAccessEntries[0] ? githubAccessEntries[0].id : ""; + githubAccessAdding = !githubAccessEntries.length; + renderGitHubAccessState(); + if (!options.silent) { + showAppToast(`"${removedEntry.name}" was removed.`, { + tone: "success", + title: "GitHub access removed", + icon: "lucide-check-check", + dedupeKey: "github-access-feedback" + }); + announceToScreenReader("GitHub access token removed."); + } + } + + function validateGitHubPat(token) { + if (!token || token.length < 20 || /\s/.test(token)) { + throw new Error("Enter a valid personal access token without spaces."); + } + } + + function validateGitHubAccessName(name) { + const normalizedName = String(name || "").trim().replace(/\s+/g, " "); + if (!normalizedName) throw new Error("Enter a token name."); + if (normalizedName.length > 60) throw new Error("Keep the token name to 60 characters or fewer."); + if (githubAccessEntries.some(function(entry) { + return entry.name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase(); + })) { + throw new Error("Use a unique token name."); + } + return normalizedName; + } + + async function saveGitHubAccess() { + if (!githubImportPatInput || !githubImportPatNameInput) return; + const name = validateGitHubAccessName(githubImportPatNameInput.value); + const token = githubImportPatInput.value; + const expiresAt = parseGitHubAccessExpiry(githubImportPatExpiryInput ? githubImportPatExpiryInput.value : ""); + validateGitHubPat(token); + const entry = { + id: createGitHubAccessId(), + name, + expiresAt, + createdAt: Date.now() + }; + + githubAccessEntries.push(entry); + githubSessionAccessTokens.set(entry.id, token); + githubSelectedAccessId = entry.id; + githubAccessAdding = false; + resetGitHubAccessForm(); + renderGitHubAccessState(); + showAppToast(`"${name}" is ready for this session.`, { + tone: "success", + title: "GitHub access added", + icon: "lucide-check-check", + dedupeKey: "github-access-feedback" + }); + announceToScreenReader("GitHub access token added."); + } + + function renderGitHubRepositoryContext(context) { + if (!githubImportRepositoryContext) return; + if (!context) { + githubImportRepositoryContext.hidden = true; + return; + } + githubImportRepositoryContext.hidden = false; + if (githubImportRepositoryName) { + githubImportRepositoryName.textContent = `${context.owner}/${context.repo}`; + } + if (githubImportCommit) { + githubImportCommit.textContent = context.commitSha.slice(0, 7); + } + if (githubImportCommitLink) { + githubImportCommitLink.href = `https://github.com/${encodeURIComponent(context.owner)}/${encodeURIComponent(context.repo)}/tree/${encodeURIComponent(context.commitSha)}`; + githubImportCommitLink.title = `Open commit ${context.commitSha} on GitHub`; + githubImportCommitLink.setAttribute("aria-label", `Open commit ${context.commitSha} on GitHub`); + } + if (githubImportRef) { + githubImportRef.textContent = context.refName; + } + if (githubImportRefItem) { + githubImportRefItem.hidden = !context.hasExplicitRef; + } + if (githubImportBasePath) { + githubImportBasePath.textContent = context.basePath ? `/ ${context.basePath}` : ""; + githubImportBasePath.hidden = !context.basePath; + } + } + + function isPathInsideGitHubBase(path, normalizedBasePath) { + return !normalizedBasePath || path === normalizedBasePath || path.startsWith(normalizedBasePath + "/"); + } + + function shouldTraverseGitHubTree(path, normalizedBasePath) { + if (!normalizedBasePath) return true; + return path === normalizedBasePath + || normalizedBasePath.startsWith(path + "/") + || path.startsWith(normalizedBasePath + "/"); + } + + async function listMarkdownFilesFromGitHubSubtrees(owner, repo, ref, normalizedBasePath, authenticated) { + const pendingTrees = [{ path: "", ref }]; + const markdownPaths = new Set(); + + for (let index = 0; index < pendingTrees.length; index++) { + const currentTree = pendingTrees[index]; + const treeResponse = await fetchGitHubJson( + `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(currentTree.ref)}`, + { authenticated } + ); + + (treeResponse.tree || []).forEach((entry) => { + const fullPath = currentTree.path ? `${currentTree.path}/${entry.path}` : entry.path; + if (entry.type === "blob" && isMarkdownPath(fullPath) && isPathInsideGitHubBase(fullPath, normalizedBasePath)) { + markdownPaths.add(fullPath); + } else if (entry.type === "tree" && shouldTraverseGitHubTree(fullPath, normalizedBasePath)) { + pendingTrees.push({ path: fullPath, ref: entry.sha }); + } + }); + } + + return Array.from(markdownPaths).sort((a, b) => a.localeCompare(b)); + } + + async function listMarkdownFiles(owner, repo, ref, basePath, options = {}) { + const authenticated = options.authenticated === true; + const treeResponse = await fetchGitHubJson( + `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(ref)}?recursive=1`, + { authenticated } + ); const normalizedBasePath = (basePath || "").replace(/^\/+|\/+$/g, ""); + if (treeResponse.truncated) { + if (typeof options.onTruncatedTree === "function") { + options.onTruncatedTree(); + } + return listMarkdownFilesFromGitHubSubtrees(owner, repo, ref, normalizedBasePath, authenticated); + } + return (treeResponse.tree || []) .filter((entry) => entry.type === "blob" && isMarkdownPath(entry.path)) - .filter((entry) => !normalizedBasePath || entry.path === normalizedBasePath || entry.path.startsWith(normalizedBasePath + "/")) + .filter((entry) => isPathInsideGitHubBase(entry.path, normalizedBasePath)) .map((entry) => entry.path) .sort((a, b) => a.localeCompare(b)); } @@ -11377,14 +12055,21 @@ ${selector} .arrowheadPath { function updateGitHubImportSelectedCount() { if (!githubImportSelectedCount) return; const count = selectedGitHubImportPaths.size; - githubImportSelectedCount.textContent = `${count} selected`; + githubImportSelectedCount.textContent = `${count.toLocaleString()} selected`; } function updateGitHubSelectAllButtonLabel() { if (!githubImportSelectAllBtn) return; const total = availableGitHubImportPaths.length; const allSelected = total > 0 && selectedGitHubImportPaths.size === total; - githubImportSelectAllBtn.textContent = allSelected ? "Clear All" : "Select All"; + const actionLabel = allSelected ? "Deselect all files" : "Select all files"; + githubImportSelectAllBtn.setAttribute("aria-label", actionLabel); + githubImportSelectAllBtn.title = actionLabel; + githubImportSelectAllBtn.setAttribute("aria-pressed", allSelected ? "true" : "false"); + const icon = githubImportSelectAllBtn.querySelector("i"); + if (icon) { + icon.className = `lucide ${allSelected ? "lucide-square-x" : "lucide-check-check"}`; + } } function syncGitHubSelectionToButtons() { @@ -11393,6 +12078,9 @@ ${selector} .arrowheadPath { const isSelected = selectedGitHubImportPaths.has(btn.dataset.path); btn.classList.toggle("is-selected", isSelected); btn.setAttribute("aria-pressed", isSelected ? "true" : "false"); + btn.setAttribute("aria-label", `${btn.dataset.name || getFileName(btn.dataset.path)}, ${isSelected ? "selected" : "not selected"}`); + const checkIcon = btn.querySelector(".github-tree-selection-icon"); + if (checkIcon) checkIcon.hidden = !isSelected; }); } @@ -11416,22 +12104,126 @@ ${selector} .arrowheadPath { updateGitHubSelectAllButtonLabel(); } + function getFilteredGitHubImportPaths() { + const query = githubImportSearchInput ? githubImportSearchInput.value.trim().toLocaleLowerCase() : ""; + if (!query) return availableGitHubImportPaths.slice(); + return availableGitHubImportPaths.filter((path) => path.toLocaleLowerCase().includes(query)); + } + + function getGitHubImportFolderPaths(paths) { + const folderPaths = new Set(); + (paths || []).forEach(function(path) { + const segments = String(path || "").split("/").filter(Boolean); + segments.pop(); + for (let index = 1; index <= segments.length; index++) { + folderPaths.add(segments.slice(0, index).join("/")); + } + }); + return Array.from(folderPaths); + } + + function updateGitHubFolderToggleButton() { + if (!githubImportToggleFoldersBtn) return; + const folderPaths = getGitHubImportFolderPaths(availableGitHubImportPaths); + const searchActive = Boolean(githubImportSearchInput && githubImportSearchInput.value.trim()); + const allCollapsed = folderPaths.length > 0 && folderPaths.every(function(path) { + return collapsedGitHubImportFolders.has(path); + }); + const actionLabel = allCollapsed ? "Expand all folders" : "Collapse all folders"; + githubImportToggleFoldersBtn.disabled = searchActive || !folderPaths.length; + githubImportToggleFoldersBtn.setAttribute("aria-label", actionLabel); + githubImportToggleFoldersBtn.title = searchActive ? "Clear search to change all folders" : actionLabel; + githubImportToggleFoldersBtn.setAttribute("aria-pressed", allCollapsed ? "true" : "false"); + const icon = githubImportToggleFoldersBtn.querySelector("i"); + if (icon) { + icon.className = `lucide ${allCollapsed ? "lucide-unfold-vertical" : "lucide-fold-vertical"}`; + } + } + + function toggleAllGitHubImportFolders() { + const folderPaths = getGitHubImportFolderPaths(availableGitHubImportPaths); + if (!folderPaths.length) return; + const shouldExpand = folderPaths.every(function(path) { + return collapsedGitHubImportFolders.has(path); + }); + collapsedGitHubImportFolders.clear(); + if (!shouldExpand) { + folderPaths.forEach(function(path) { + collapsedGitHubImportFolders.add(path); + }); + } + renderGitHubImportTree(); + announceToScreenReader(shouldExpand ? "All GitHub folders expanded." : "All GitHub folders collapsed."); + } + + function countMarkdownFilesInTreeNode(node) { + return node.files.length + Object.values(node.folders).reduce((total, folder) => { + return total + countMarkdownFilesInTreeNode(folder); + }, 0); + } + function renderGitHubImportTree(paths) { if (!githubImportTree || !githubImportFileSelect) return; - githubImportTree.innerHTML = ""; - const tree = buildMarkdownFileTree(paths); + githubImportTree.textContent = ""; + const visiblePaths = Array.isArray(paths) ? paths : getFilteredGitHubImportPaths(); + const searchActive = Boolean(githubImportSearchInput && githubImportSearchInput.value.trim()); + if (!visiblePaths.length) { + const emptyState = document.createElement("div"); + emptyState.className = "github-import-tree-empty"; + emptyState.setAttribute("role", "status"); + emptyState.innerHTML = 'No Markdown files match your search.'; + githubImportTree.appendChild(emptyState); + updateGitHubFolderToggleButton(); + return; + } + const tree = buildMarkdownFileTree(visiblePaths); const createTreeBranch = function(node, parentPath) { const list = document.createElement("ul"); + list.setAttribute("role", "group"); const folderNames = Object.keys(node.folders).sort((a, b) => a.localeCompare(b)); folderNames.forEach((folderName) => { const folderPath = parentPath ? `${parentPath}/${folderName}` : folderName; const item = document.createElement("li"); - const folderLabel = document.createElement("span"); - folderLabel.className = "github-tree-folder-label"; - folderLabel.textContent = `📁 ${folderName}`; - item.appendChild(folderLabel); - item.appendChild(createTreeBranch(node.folders[folderName], folderPath)); + item.className = "github-tree-folder-item"; + item.setAttribute("role", "none"); + const folderToggle = document.createElement("button"); + const childBranch = createTreeBranch(node.folders[folderName], folderPath); + const collapsed = !searchActive && collapsedGitHubImportFolders.has(folderPath); + folderToggle.type = "button"; + folderToggle.className = "github-tree-folder-label"; + folderToggle.dataset.folderPath = folderPath; + folderToggle.setAttribute("role", "treeitem"); + folderToggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); + folderToggle.title = folderPath; + + const chevron = document.createElement("i"); + chevron.className = `lucide ${collapsed ? "lucide-chevron-right" : "lucide-chevron-down"} github-tree-chevron`; + chevron.setAttribute("aria-hidden", "true"); + const folderIcon = document.createElement("i"); + folderIcon.className = `lucide ${collapsed ? "lucide-folder" : "lucide-folder-open"} github-tree-folder-icon`; + folderIcon.setAttribute("aria-hidden", "true"); + const folderNameNode = document.createElement("span"); + folderNameNode.className = "github-import-tree-name"; + folderNameNode.textContent = folderName; + const folderCount = document.createElement("span"); + folderCount.className = "github-tree-folder-count"; + folderCount.textContent = countMarkdownFilesInTreeNode(node.folders[folderName]).toLocaleString(); + folderCount.setAttribute("aria-label", `${folderCount.textContent} Markdown files`); + + childBranch.hidden = collapsed; + folderToggle.append(chevron, folderIcon, folderNameNode, folderCount); + folderToggle.addEventListener("click", function() { + const shouldCollapse = folderToggle.getAttribute("aria-expanded") === "true"; + folderToggle.setAttribute("aria-expanded", shouldCollapse ? "false" : "true"); + childBranch.hidden = shouldCollapse; + chevron.className = `lucide ${shouldCollapse ? "lucide-chevron-right" : "lucide-chevron-down"} github-tree-chevron`; + folderIcon.className = `lucide ${shouldCollapse ? "lucide-folder" : "lucide-folder-open"} github-tree-folder-icon`; + if (shouldCollapse) collapsedGitHubImportFolders.add(folderPath); + else collapsedGitHubImportFolders.delete(folderPath); + updateGitHubFolderToggleButton(); + }); + item.append(folderToggle, childBranch); list.appendChild(item); }); @@ -11439,12 +12231,29 @@ ${selector} .arrowheadPath { .sort((a, b) => a.path.localeCompare(b.path)) .forEach((file) => { const fileItem = document.createElement("li"); + fileItem.className = "github-tree-file-item"; + fileItem.setAttribute("role", "none"); const fileButton = document.createElement("button"); fileButton.type = "button"; fileButton.className = "github-tree-file-btn"; fileButton.dataset.path = file.path; + fileButton.dataset.name = file.name; + fileButton.setAttribute("role", "treeitem"); fileButton.setAttribute("aria-pressed", "false"); - fileButton.textContent = `📄 ${file.name}`; + fileButton.title = file.path; + + const fileIcon = document.createElement("i"); + fileIcon.className = "lucide lucide-file-text github-tree-file-icon"; + fileIcon.setAttribute("aria-hidden", "true"); + const fileName = document.createElement("span"); + fileName.className = "github-import-tree-name"; + fileName.textContent = file.name; + const selectedIcon = document.createElement("i"); + selectedIcon.className = "lucide lucide-check github-tree-selection-icon"; + selectedIcon.setAttribute("aria-hidden", "true"); + selectedIcon.hidden = true; + + fileButton.append(fileIcon, fileName, selectedIcon); fileButton.addEventListener("click", function() { toggleGitHubSelectedPath(file.path); }); @@ -11455,8 +12264,11 @@ ${selector} .arrowheadPath { return list; }; - githubImportTree.appendChild(createTreeBranch(tree, "")); + const treeFragment = document.createDocumentFragment(); + treeFragment.appendChild(createTreeBranch(tree, "")); + githubImportTree.appendChild(treeFragment); syncGitHubSelectionToButtons(); + updateGitHubFolderToggleButton(); } function setGitHubImportLoading(isLoading) { @@ -11487,12 +12299,22 @@ ${selector} .arrowheadPath { function resetGitHubImportModal() { if (!githubImportUrlInput || !githubImportFileSelect || !githubImportSubmitBtn) return; + if (githubImportModal) githubImportModal.classList.remove("is-selection-step"); if (githubImportTitle) { githubImportTitle.textContent = "Import Markdown from GitHub"; } + if (githubImportSubtitle) { + githubImportSubtitle.textContent = "Paste a GitHub file or repository URL."; + } githubImportUrlInput.value = ""; githubImportUrlInput.style.display = "block"; if (githubImportUrlLabel) githubImportUrlLabel.style.display = "block"; + if (githubImportAccess) githubImportAccess.hidden = false; + if (githubImportAccessToggle) githubImportAccessToggle.setAttribute("aria-expanded", "false"); + if (githubImportAccessPanel) githubImportAccessPanel.hidden = true; + resetGitHubAccessForm(); + githubAccessAdding = !githubAccessEntries.length; + renderGitHubRepositoryContext(null); githubImportUrlInput.disabled = false; githubImportFileSelect.innerHTML = ""; githubImportFileSelect.style.display = "none"; @@ -11501,6 +12323,14 @@ ${selector} .arrowheadPath { githubImportSelectionToolbar.style.display = "none"; } availableGitHubImportPaths = []; + collapsedGitHubImportFolders.clear(); + clearTimeout(githubImportSearchTimer); + githubImportSearchTimer = null; + if (githubImportSearchInput) { + githubImportSearchInput.value = ""; + githubImportSearchInput.disabled = false; + } + updateGitHubFolderToggleButton(); setGitHubSelectedPaths([]); if (githubImportTree) { githubImportTree.innerHTML = ""; @@ -11510,8 +12340,10 @@ ${selector} .arrowheadPath { delete githubImportSubmitBtn.dataset.owner; delete githubImportSubmitBtn.dataset.repo; delete githubImportSubmitBtn.dataset.ref; + githubImportSubmitBtn._githubContext = null; githubImportSubmitBtn.textContent = "Import"; setGitHubImportMessage(""); + renderGitHubAccessState(); } function openGitHubImportModal() { @@ -11521,6 +12353,7 @@ ${selector} .arrowheadPath { focusTarget: githubImportUrlInput, onClose: closeGitHubImportModal }); + void loadStoredGitHubAccess(); } function closeGitHubImportModal() { @@ -11539,19 +12372,37 @@ ${selector} .arrowheadPath { if (githubImportSelectAllBtn) { githubImportSelectAllBtn.disabled = disabled; } + if (githubImportToggleFoldersBtn) { + githubImportToggleFoldersBtn.disabled = disabled; + } + if (githubImportSearchInput) { + githubImportSearchInput.disabled = disabled; + } + [ + githubImportAccessToggle, + githubImportPatSelect, + githubImportPatAddAnotherBtn, + githubImportPatAddCancelBtn, + githubImportPatNameInput, + githubImportPatExpiryInput, + githubImportPatInput, + githubImportPatSaveBtn, + githubImportPatRemoveBtn + ].forEach(function(control) { + if (control) control.disabled = disabled; + }); + if (!disabled) updateGitHubFolderToggleButton(); }; const step = githubImportSubmitBtn.dataset.step || "url"; if (step === "select") { const selectedPaths = Array.from(selectedGitHubImportPaths); - const owner = githubImportSubmitBtn.dataset.owner; - const repo = githubImportSubmitBtn.dataset.repo; - const ref = githubImportSubmitBtn.dataset.ref; - if (!owner || !repo || !ref || !selectedPaths.length) { + const context = githubImportSubmitBtn._githubContext; + if (!context || !context.owner || !context.repo || !context.commitSha || !selectedPaths.length) { setGitHubImportMessage("Please select at least one file to import."); return; } announceToScreenReader("Importing selected files from GitHub..."); - startGitHubImport(owner, repo, ref, selectedPaths); + startGitHubImport(context, selectedPaths); return; } @@ -11571,12 +12422,13 @@ ${selector} .arrowheadPath { setGitHubImportLoading(true); setGitHubImportDialogDisabled(true); try { - if (parsed.type === "file") { - if (!isMarkdownPath(parsed.filePath)) { + const context = await resolveGitHubLocation(parsed); + if (context.type === "file") { + if (!isMarkdownPath(context.filePath)) { throw new Error("The provided URL does not point to a Markdown file."); } announceToScreenReader("Starting GitHub import..."); - startGitHubImport(parsed.owner, parsed.repo, parsed.ref, [parsed.filePath]); + startGitHubImport(context, [context.filePath]); return; } @@ -11590,8 +12442,13 @@ ${selector} .arrowheadPath { githubImportTree.style.display = "block"; } - const ref = parsed.ref || await getDefaultBranch(parsed.owner, parsed.repo); - const files = await listMarkdownFiles(parsed.owner, parsed.repo, ref, parsed.basePath || ""); + const files = await listMarkdownFiles(context.owner, context.repo, context.commitSha, context.basePath, { + authenticated: context.authenticated, + onTruncatedTree: function() { + setGitHubImportMessage("GitHub returned a partial tree. Scanning every folder to find all Markdown files\u2026", { isError: false }); + announceToScreenReader("Large GitHub repository detected. Scanning every folder."); + } + }); if (!files.length) { if (githubImportTree) { @@ -11603,53 +12460,56 @@ ${selector} .arrowheadPath { return; } - const shownFiles = files.slice(0, MAX_GITHUB_FILES_SHOWN); if (files.length === 1) { const targetPath = files[0]; announceToScreenReader("Starting GitHub import..."); - startGitHubImport(parsed.owner, parsed.repo, ref, [targetPath]); + startGitHubImport(context, [targetPath]); return; } githubImportFileSelect.innerHTML = ""; + if (githubImportModal) githubImportModal.classList.add("is-selection-step"); githubImportUrlInput.style.display = "none"; if (githubImportUrlLabel) githubImportUrlLabel.style.display = "none"; + if (githubImportAccess) githubImportAccess.hidden = true; + renderGitHubRepositoryContext(context); githubImportFileSelect.style.display = "none"; if (githubImportSelectionToolbar) { - githubImportSelectionToolbar.style.display = "flex"; + githubImportSelectionToolbar.style.display = "grid"; } if (githubImportTree) { githubImportTree.style.display = "block"; } - shownFiles.forEach((filePath) => { - const option = document.createElement("option"); - option.value = filePath; - option.textContent = filePath; - githubImportFileSelect.appendChild(option); - }); - availableGitHubImportPaths = shownFiles.slice(); - setGitHubSelectedPaths(shownFiles[0] ? [shownFiles[0]] : []); - renderGitHubImportTree(shownFiles); + availableGitHubImportPaths = files.slice(); + setGitHubSelectedPaths(files[0] ? [files[0]] : []); + renderGitHubImportTree(); // Announce load complete announceToScreenReader("GitHub files loaded. " + files.length + " files available in the tree."); - if (files.length > MAX_GITHUB_FILES_SHOWN) { - setGitHubImportMessage(`Showing first ${MAX_GITHUB_FILES_SHOWN} of ${files.length} Markdown files.`, { isError: false }); - } else { - setGitHubImportMessage(""); - } + setGitHubImportMessage(""); if (githubImportTitle) { - githubImportTitle.textContent = "Select Markdown file(s) to import"; + githubImportTitle.textContent = "Select Markdown files to import"; + } + if (githubImportSubtitle) { + githubImportSubtitle.textContent = `${files.length.toLocaleString()} Markdown files found. Choose what to save to Explorer.`; } githubImportSubmitBtn.dataset.step = "select"; - githubImportSubmitBtn.dataset.owner = parsed.owner; - githubImportSubmitBtn.dataset.repo = parsed.repo; - githubImportSubmitBtn.dataset.ref = ref; + githubImportSubmitBtn.dataset.owner = context.owner; + githubImportSubmitBtn.dataset.repo = context.repo; + githubImportSubmitBtn.dataset.ref = context.commitSha; + githubImportSubmitBtn._githubContext = context; githubImportSubmitBtn.textContent = "Import Selected"; + if (githubImportSearchInput) githubImportSearchInput.focus(); } catch (error) { console.error("GitHub import failed:", error); setGitHubImportMessage("GitHub import failed: " + error.message); + if (githubImportAccessToggle && githubImportAccessPanel + && /^".+" (?:is no longer available|cannot access|was rejected)/.test(error.message || "")) { + githubImportAccessToggle.setAttribute("aria-expanded", "true"); + githubImportAccessPanel.hidden = false; + renderGitHubAccessState(); + } announceToScreenReader("GitHub import failed."); if (githubImportTree) { githubImportTree.innerHTML = ""; @@ -17825,11 +18685,26 @@ ${selector} .arrowheadPath { multiSelections: true }); if (result && result.length > 0) { - for (const filePath of result) { - const content = await Neutralino.filesystem.readFile(filePath); - const fileName = filePath.split(/[/\\]/).pop().replace(/\.(md|markdown)$/i, ""); - if (!newTab(content, fileName)) break; + const importedDocumentIds = []; + showImportProgress(result.length); + for (let index = 0; index < result.length; index++) { + const filePath = result[index]; + try { + const content = await Neutralino.filesystem.readFile(filePath); + const fileName = filePath.split(/[/\\]/).pop().replace(/\.(md|markdown)$/i, ""); + const importedDocument = createImportedDocument(content, fileName); + if (importedDocument) importedDocumentIds.push(importedDocument.id); + } catch (fileError) { + console.error("Native file import failed:", filePath, fileError); + } + updateImportProgress(index + 1, result.length); } + await persistImportedDocuments(importedDocumentIds); + finishImportProgress(importedDocumentIds.length, result.length, { + detail: importedDocumentIds.length === result.length + ? 'Saved to Explorer without opening new tabs.' + : undefined + }); } } catch (e) { console.error("Native import failed:", e); @@ -17864,6 +18739,84 @@ ${selector} .arrowheadPath { if (githubImportCloseBtn) { githubImportCloseBtn.addEventListener("click", closeGitHubImportModal); } + if (githubImportAccessToggle && githubImportAccessPanel) { + githubImportAccessToggle.addEventListener("click", function() { + const expanded = githubImportAccessToggle.getAttribute("aria-expanded") === "true"; + githubImportAccessToggle.setAttribute("aria-expanded", expanded ? "false" : "true"); + githubImportAccessPanel.hidden = expanded; + if (!expanded) { + const target = githubAccessAdding ? githubImportPatNameInput : githubImportPatSelect; + if (target) { + requestAnimationFrame(function() { + target.focus(); + target.scrollIntoView({ block: "nearest" }); + }); + } + } + }); + } + if (githubImportPatSelect) { + githubImportPatSelect.addEventListener("change", function() { + githubSelectedAccessId = githubImportPatSelect.value; + renderGitHubAccessState(); + }); + } + if (githubImportPatAddAnotherBtn) { + githubImportPatAddAnotherBtn.addEventListener("click", function() { + setGitHubAccessAdding(true); + }); + } + if (githubImportPatAddCancelBtn) { + githubImportPatAddCancelBtn.addEventListener("click", function() { + setGitHubAccessAdding(false, { focus: false }); + }); + } + if (githubImportPatSaveBtn) { + githubImportPatSaveBtn.addEventListener("click", async function() { + const previousText = githubImportPatSaveBtn.textContent; + githubImportPatSaveBtn.disabled = true; + githubImportPatSaveBtn.textContent = "Adding…"; + try { + await saveGitHubAccess(); + } catch (error) { + showAppToast(error && error.message ? error.message : "Private access could not be saved.", { + tone: "error", + title: "GitHub access not added", + dedupeKey: "github-access-feedback" + }); + } finally { + githubImportPatSaveBtn.disabled = false; + githubImportPatSaveBtn.textContent = previousText; + } + }); + } + if (githubImportPatRemoveBtn) { + githubImportPatRemoveBtn.addEventListener("click", async function() { + githubImportPatRemoveBtn.disabled = true; + try { + await removeGitHubAccess(); + } catch (_) { + showAppToast("Private access could not be removed.", { + tone: "error", + title: "GitHub access not removed", + dedupeKey: "github-access-feedback" + }); + } finally { + githubImportPatRemoveBtn.disabled = false; + } + }); + } + const bindGitHubAccessEnterAction = function(input, actionButton) { + if (!input || !actionButton) return; + input.addEventListener("keydown", function(event) { + if (event.key === "Enter") { + event.preventDefault(); + actionButton.click(); + } + }); + }; + bindGitHubAccessEnterAction(githubImportPatInput, githubImportPatSaveBtn); + void loadStoredGitHubAccess(); const handleGitHubImportInputKeydown = function(e) { if (e.key === "Enter") { e.preventDefault(); @@ -17878,6 +18831,24 @@ ${selector} .arrowheadPath { if (githubImportFileSelect) { githubImportFileSelect.addEventListener("keydown", handleGitHubImportInputKeydown); } + if (githubImportSearchInput) { + githubImportSearchInput.addEventListener("input", function() { + clearTimeout(githubImportSearchTimer); + githubImportSearchTimer = setTimeout(function() { + renderGitHubImportTree(); + const visibleCount = getFilteredGitHubImportPaths().length; + announceToScreenReader(visibleCount + " Markdown file" + (visibleCount === 1 ? "" : "s") + " shown."); + }, 120); + }); + githubImportSearchInput.addEventListener("keydown", function(e) { + if (e.key === "Escape" && githubImportSearchInput.value) { + e.preventDefault(); + e.stopPropagation(); + githubImportSearchInput.value = ""; + renderGitHubImportTree(); + } + }); + } if (githubImportSelectAllBtn) { githubImportSelectAllBtn.addEventListener("click", function() { const allPaths = availableGitHubImportPaths.slice(); @@ -17885,6 +18856,9 @@ ${selector} .arrowheadPath { setGitHubSelectedPaths(shouldSelectAll ? allPaths : []); }); } + if (githubImportToggleFoldersBtn) { + githubImportToggleFoldersBtn.addEventListener("click", toggleAllGitHubImportFolders); + } fileInput.addEventListener("change", async function (e) { try { diff --git a/styles.css b/styles.css index 52f2e28c..f42bc59f 100644 --- a/styles.css +++ b/styles.css @@ -5945,111 +5945,656 @@ a:focus { #github-import-modal .reset-modal-box { width: min(92vw, 520px); max-width: 520px; + max-height: calc(100dvh - 40px); min-width: 0; padding: 0; gap: 0; overflow: hidden; } -#github-import-modal .reset-modal-message { - font-size: var(--ui-font-lg); - line-height: 1.3; - text-align: left; +#github-import-modal.is-selection-step .reset-modal-box { + width: min(94vw, 760px); + max-width: 760px; +} + +#github-import-modal .reset-modal-message { + font-size: var(--ui-font-lg); + line-height: 1.3; + text-align: left; +} + +#github-import-modal .modal-header { + padding: 14px 16px; + border-bottom: 1px solid var(--border-color); +} + +.github-import-modal-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 10px; + min-height: 0; + max-height: none; + overflow-x: hidden; + overflow-y: auto; + padding: 16px; + overscroll-behavior: contain; + scrollbar-color: color-mix(in srgb, var(--text-secondary) 45%, transparent) transparent; + scrollbar-gutter: stable; + scrollbar-width: thin; +} + +.github-import-modal-body::-webkit-scrollbar { + width: 8px; +} + +.github-import-modal-body::-webkit-scrollbar-track { + background: transparent; +} + +.github-import-modal-body::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: 999px; + background: color-mix(in srgb, var(--text-secondary) 45%, transparent); + background-clip: padding-box; +} + +#github-import-modal.is-selection-step .github-import-modal-body { + overflow: hidden; +} + +#github-import-url, +#github-import-file-select { + min-height: 36px; + padding: 7px 10px; + font-size: var(--ui-font-md); +} + +#github-import-file-select { + min-height: 180px; +} + +.github-import-access[hidden], +.github-import-access-panel[hidden], +.github-import-access-manage[hidden], +.github-import-access-form[hidden], +.github-import-remove-token-btn[hidden], +.github-import-repository-context[hidden], +.github-import-ref[hidden], +.github-import-base-path[hidden] { + display: none !important; +} + +.github-import-access { + flex: 0 0 auto; + overflow: hidden; + border: 1px solid var(--border-color); + border-radius: 9px; + background: var(--bg-color); +} + +.github-import-access-toggle { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 48px; + padding: 8px 10px; + border: 0; + background: transparent; + color: var(--text-color); + cursor: pointer; + text-align: left; + font: inherit; +} + +.github-import-access-toggle:hover, +.github-import-access-toggle:focus-visible { + background: var(--button-hover); +} + +.github-import-access-toggle:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: -2px; +} + +.github-import-access-icon { + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + flex: 0 0 auto; + border-radius: 8px; + background: color-mix(in srgb, var(--text-secondary) 10%, transparent); + color: var(--text-secondary); +} + +.github-import-access-icon > .lucide { + width: 17px; + height: 17px; +} + +.github-import-access[data-state="active"] .github-import-access-icon { + background: color-mix(in srgb, #1a7f37 13%, transparent); + color: #1a7f37; +} + +.github-import-access-copy { + display: grid; + gap: 2px; + min-width: 0; + flex: 1; +} + +.github-import-access-title { + font-size: var(--ui-font-md); + font-weight: 700; +} + +.github-import-access-summary { + overflow: hidden; + color: var(--text-secondary); + font-size: var(--ui-font-sm); + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.github-import-access-chevron { + width: 16px; + height: 16px; + flex: 0 0 auto; + color: var(--text-secondary); + transition: transform 160ms ease; +} + +.github-import-access-toggle[aria-expanded="true"] .github-import-access-chevron { + transform: rotate(180deg); +} + +.github-import-access-panel { + display: grid; + gap: 10px; + padding: 12px; + border-top: 1px solid var(--border-color); + background: var(--bg-color); +} + +.github-import-access-form-heading, +.github-import-access-manage-header, +.github-import-access-form-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.github-import-access-form-heading { + font-size: var(--ui-font-md); +} + +.github-import-access-field { + display: grid; + gap: 6px; + min-width: 0; +} + +.github-import-access-form, +.github-import-access-manage { + display: grid; + gap: 10px; +} + +.github-import-access-form { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.github-import-access-form-heading, +.github-import-access-token-field, +.github-import-access-form-footer { + grid-column: 1 / -1; +} + +.github-import-access-form .reset-modal-label, +.github-import-access-manage .reset-modal-label, +.github-import-access-form-footer > button { + white-space: nowrap; +} + +.github-import-access-selected-meta { + display: block; + min-width: 0; + overflow: hidden; + color: var(--text-secondary); + font-size: var(--ui-font-sm); + text-overflow: ellipsis; + white-space: nowrap; +} + +.github-import-access-panel .rename-modal-input { + width: 100%; + min-height: 38px; + padding: 7px 9px; + font-size: var(--ui-font-md); +} + +.github-import-toolbar-actions { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + gap: 4px; +} + +.github-import-toolbar-btn.tool-button { + width: 30px; + height: 30px; + min-width: 30px; + padding: 0; + border-color: transparent; + border-radius: 7px; + background: transparent; +} + +.github-import-toolbar-btn.tool-button:hover, +.github-import-toolbar-btn.tool-button:focus-visible { + border-color: transparent; + background: var(--toolbar-control-hover); +} + +.github-import-toolbar-btn > .lucide { + width: 16px; + height: 16px; +} + +.github-import-remove-token-btn { + color: var(--color-danger-fg, #cf222e); +} + +.github-import-access-form-footer { + padding-top: 2px; +} + +.github-import-access-session-note { + min-width: 0; + overflow: hidden; + color: var(--text-secondary); + font-size: var(--ui-font-sm); + text-overflow: ellipsis; + white-space: nowrap; +} + +.github-import-optional-label { + color: var(--text-secondary); + font-size: var(--ui-font-sm); + font-weight: 400; +} + +.github-import-access-form-footer .reset-modal-btn { + min-height: 34px; + padding: 7px 14px; + font-size: var(--ui-font-md); +} + +.github-import-repository-context { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + padding: 9px 10px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: color-mix(in srgb, var(--button-bg) 62%, var(--bg-color)); +} + +.github-import-repository-icon { + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + flex: 0 0 auto; + border-radius: 8px; + background: var(--bg-color); + color: var(--text-color); +} + +.github-import-repository-icon > .lucide, +.github-import-repository-icon > .bi { + width: 18px; + height: 18px; + font-size: 18px; + line-height: 1; +} + +.github-import-repository-copy { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + flex: 1 1 auto; + overflow: hidden; +} + +.github-import-repository-heading, +.github-import-repository-meta, +.github-import-ref, +.github-import-commit-link { + display: flex; + align-items: center; +} + +.github-import-repository-heading { + flex: 1 1 auto; + gap: 8px; + min-width: 0; +} + +.github-import-repository-heading strong { + overflow: hidden; + color: var(--text-color); + font-size: var(--ui-font-lg); + text-overflow: ellipsis; + white-space: nowrap; +} + +.github-import-commit-link { + gap: 4px; + flex: 0 0 auto; + padding: 2px 7px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-color); + color: var(--accent-color); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: var(--ui-font-sm); + font-weight: 650; + text-decoration: none; +} + +.github-import-commit-link > .lucide, +.github-import-ref > .lucide { + width: 13px; + height: 13px; +} + +.github-import-commit-link:hover { + border-color: var(--accent-color); +} + +.github-import-commit-link:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: 2px; +} + +.github-import-repository-meta { + flex: 0 1 auto; + gap: 7px; + min-width: 0; + color: var(--text-secondary); + font-size: var(--ui-font-sm); + white-space: nowrap; +} + +.github-import-ref { + flex: 0 1 auto; + gap: 4px; + min-width: 0; +} + +.github-import-ref > span, +.github-import-base-path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.github-import-tree { + min-height: 240px; + max-height: min(48vh, 440px); + overflow: auto; + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 0; + background: var(--bg-color); + overscroll-behavior: contain; + scrollbar-gutter: stable; } -#github-import-modal .modal-header { - padding: 14px 16px; - border-bottom: 1px solid var(--border-color); +.github-import-selection-toolbar { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto; + align-items: center; + gap: 8px; } -.github-import-modal-body { +.github-import-search { + position: relative; display: flex; - flex-direction: column; - gap: 8px; - max-height: min(56vh, 460px); - overflow: auto; - padding: 16px; + align-items: center; + min-width: 0; + margin: 0; } -#github-import-url, -#github-import-file-select { - min-height: 36px; - padding: 7px 10px; +.github-import-search > .lucide { + position: absolute; + left: 11px; + z-index: 1; + width: 16px; + height: 16px; + color: var(--text-secondary); + pointer-events: none; +} + +#github-import-search { + width: 100%; + min-width: 0; + min-height: 40px; + padding: 8px 12px 8px 36px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-color); + color: var(--text-color); + font: inherit; font-size: var(--ui-font-md); } -#github-import-file-select { - min-height: 180px; +#github-import-search::placeholder { + color: var(--text-secondary); } -.github-import-tree { - max-height: 320px; - overflow: auto; - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 12px; - background: var(--bg-color); +#github-import-search:focus-visible { + border-color: var(--accent-color); + outline: 2px solid color-mix(in srgb, var(--accent-color) 24%, transparent); + outline-offset: 1px; } -.github-import-selection-toolbar { - display: flex; +.github-import-selection-actions { + display: inline-flex; align-items: center; - justify-content: space-between; + justify-content: flex-end; gap: 8px; - padding: 8px 10px; - border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--button-bg); + min-width: max-content; } .github-import-selected-count { - font-size: var(--ui-font-md); - font-weight: 600; + display: inline-flex; + align-items: center; + min-height: 30px; + margin: 0; + padding: 0 9px; + border-radius: 7px; + background: var(--toolbar-control-hover); color: var(--text-color); + font-size: var(--ui-font-sm); + font-weight: 600; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.github-import-toolbar-btn[aria-pressed="true"] { + border-color: transparent; + color: var(--accent-color); + background-color: rgba(3, 102, 214, 0.08); } .github-import-tree ul { list-style: none; margin: 0; - padding-left: 18px; + padding-left: 24px; +} + +.github-import-tree ul ul { + margin-left: 16px; + padding-left: 8px; + border-left: 1px solid color-mix(in srgb, var(--border-color) 76%, transparent); } .github-import-tree>ul { - padding-left: 4px; + padding-left: 0; +} + +.github-import-tree>ul>li:last-child>.github-tree-file-btn, +.github-import-tree>ul>li:last-child>.github-tree-folder-label { + border-bottom-color: transparent; } .github-import-tree li { - margin: 2px 0; + margin: 0; } .github-tree-folder-label { - display: inline-block; - font-size: 14px; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 40px; + padding: 7px 10px; + border: 0; + border-bottom: 1px solid var(--border-color); + background: transparent; + color: var(--text-color); + cursor: pointer; + text-align: left; + font: inherit; + font-size: var(--ui-font-md); +} + +.github-tree-folder-label:hover, +.github-tree-folder-label:focus-visible { + background: var(--button-hover); +} + +.github-tree-folder-label:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: -2px; +} + +.github-tree-chevron { + width: 14px; + height: 14px; color: var(--text-secondary, #57606a); - margin-bottom: 4px; + flex: 0 0 auto; +} + +.github-tree-folder-icon { + width: 17px; + height: 17px; + color: var(--accent-color); + flex: 0 0 auto; +} + +.github-tree-folder-count { + min-width: 24px; + margin-left: auto; + padding: 1px 7px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--button-bg); + color: var(--text-secondary); + font-size: 11px; + line-height: 1.45; + text-align: center; + font-variant-numeric: tabular-nums; } .github-tree-file-btn { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 40px; + padding: 7px 10px 7px 32px; border: 0; + border-bottom: 1px solid var(--border-color); background: transparent; color: var(--text-color); cursor: pointer; - padding: 6px 8px; - border-radius: 6px; text-align: left; - width: 100%; - font-size: 14px; + font: inherit; + font-size: var(--ui-font-md); } .github-tree-file-btn:hover, .github-tree-file-btn:focus-visible { background: var(--button-hover); - outline: none; +} + +.github-tree-file-btn:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: -2px; } .github-tree-file-btn.is-selected { - background: rgba(56, 139, 253, 0.14); + background: color-mix(in srgb, var(--accent-color) 12%, transparent); + color: var(--accent-color); +} + +.github-tree-file-icon { + width: 16px; + height: 16px; + color: var(--text-secondary); + flex: 0 0 auto; +} + +.github-tree-file-btn.is-selected .github-tree-file-icon { + color: var(--accent-color); +} + +.github-import-tree-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.github-tree-selection-icon { + width: 16px; + height: 16px; + margin-left: auto; color: var(--accent-color); + flex: 0 0 auto; +} + +.github-tree-selection-icon[hidden] { + display: none; +} + +.github-import-tree-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 220px; + padding: 24px; + color: var(--text-secondary); + text-align: center; + font-size: var(--ui-font-md); +} + +.github-import-tree-empty > .lucide { + width: 24px; + height: 24px; } #github-import-modal .reset-modal-actions { @@ -6074,8 +6619,45 @@ a:focus { } .github-import-selection-toolbar { - flex-direction: column; - align-items: stretch; + grid-template-columns: minmax(0, 1fr) auto; + } + + .github-import-search { + grid-column: 1 / -1; + } + + #github-import-search, + .github-import-toolbar-btn.tool-button { + min-height: 44px; + } + + .github-import-toolbar-btn.tool-button { + width: 44px; + height: 44px; + min-width: 44px; + } + + .github-import-selection-actions { + justify-content: space-between; + } + + .github-import-selected-count { + min-height: 44px; + padding-inline: 10px; + } + + .github-import-tree { + min-height: 220px; + max-height: min(46vh, 380px); + } + + .github-tree-folder-label, + .github-tree-file-btn { + min-height: 44px; + } + + .github-import-access-panel .rename-modal-input { + min-height: 44px; } #github-import-modal .reset-modal-message { @@ -6092,6 +6674,34 @@ a:focus { } } +@media (max-width: 420px) { + .github-import-access-form { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (prefers-reduced-motion: reduce) { + .github-import-toolbar-btn, + .github-import-access-chevron { + transition: none; + } +} + +@media (max-width: 900px) and (max-height: 500px) { + #github-import-search, + .github-import-toolbar-btn.tool-button, + .github-tree-folder-label, + .github-tree-file-btn { + min-height: 44px; + } + + .github-import-toolbar-btn.tool-button { + width: 44px; + height: 44px; + min-width: 44px; + } +} + .frontmatter-table { border-collapse: collapse; margin-bottom: 1.5em; @@ -6207,6 +6817,8 @@ a:focus { [dir="rtl"] .alert-option, [dir="rtl"] .github-import-error, [dir="rtl"] #github-import-modal .reset-modal-message, +[dir="rtl"] .github-import-access-toggle, +[dir="rtl"] .github-import-access-panel, [dir="rtl"] .github-tree-file-btn, [dir="rtl"] .frontmatter-table td { text-align: right; @@ -6214,11 +6826,41 @@ a:focus { [dir="rtl"] .github-import-tree ul { padding-left: 0; - padding-right: 18px; + padding-right: 24px; } [dir="rtl"] .github-import-tree>ul { - padding-right: 4px; + padding-right: 0; +} + +[dir="rtl"] .github-import-tree ul ul { + margin-right: 16px; + margin-left: 0; + padding-right: 8px; + padding-left: 0; + border-right: 1px solid color-mix(in srgb, var(--border-color) 76%, transparent); + border-left: 0; +} + +[dir="rtl"] .github-import-search > .lucide { + right: 11px; + left: auto; +} + +[dir="rtl"] #github-import-search { + padding-right: 36px; + padding-left: 12px; +} + +[dir="rtl"] .github-tree-file-btn { + padding-right: 32px; + padding-left: 10px; +} + +[dir="rtl"] .github-tree-folder-count, +[dir="rtl"] .github-tree-selection-icon { + margin-right: auto; + margin-left: 0; } [dir="rtl"] .markdown-body .markdown-alert, diff --git a/wiki/Configuration.md b/wiki/Configuration.md index 27745d5f..24d89016 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -72,7 +72,7 @@ When running inside Neutralino, dynamic library URLs are rewritten to local `/li | Minimum split pane width | 20% | | Line-height cache size | 5,000 entries | | Local Markdown import | 10 MB per file | -| GitHub importer shown files | 30 | +| GitHub importer shown files | All Markdown files found | | Share URL warning ceiling | 32,000 characters | | Legacy share URL ceiling | 4,096 characters | | Server share threshold | 3,000 bytes | diff --git a/wiki/Contributing.md b/wiki/Contributing.md index 79c7df3b..7b80ff7a 100644 --- a/wiki/Contributing.md +++ b/wiki/Contributing.md @@ -107,8 +107,6 @@ For documentation: - search for outdated terminology and unsupported claims; and - review `git diff --name-only` to confirm the intended scope. -At commit `5511dc7`, the static validator includes a pre-existing reference to missing `functions/api/report-issue.js`. If that baseline remains, `npm run build` reports the missing asset before documentation changes. Record the baseline failure accurately; do not change product source in a documentation-only pull request. - ## Issue Reports Search [existing issues](https://github.com/ThisIs-Developer/Markdown-Viewer/issues) before opening a new one. Include: diff --git a/wiki/FAQ.md b/wiki/FAQ.md index 2a8d2721..1ed5ba35 100644 --- a/wiki/FAQ.md +++ b/wiki/FAQ.md @@ -60,7 +60,7 @@ A workspace ZIP contains normal Documents, folder organization, review data, sel ### Does Markdown leave my device? -Normal editing, local import, Preview, Workspace storage, and most exports stay on the device. Data can leave when you use managed media, public GitHub import, remote diagram rendering, stored Share Snapshot, Live Share, external assets/map tiles, or uncached web libraries. +Normal editing, local import, Preview, Workspace storage, and most exports stay on the device. Data can leave when you use managed media, GitHub import, remote diagram rendering, stored Share Snapshot, Live Share, external assets/map tiles, or uncached web libraries. See [Privacy and Security](Privacy-and-Security.md) for the complete table. @@ -120,7 +120,7 @@ Yes. It supports regular expressions, capture replacements, preserve-case replac ### Can I import a private GitHub repository? -No. The importer uses public GitHub URLs and does not request a token. Repository and Folder results show at most 30 Markdown files. +Yes. Expand **Private repository access**, give the token a recognizable name, optionally choose an earlier expiry date, enter either a fine-grained or classic PAT, and select **Add access**. The app detects both formats automatically and confirms success or failure with a toast. The form then collapses into a named-token selector so you can add, select, or remove entries individually. Tokens remain in memory for the current session only and are not written to application storage, so no passphrase or unlock step is needed. The app cannot revoke a PAT on GitHub; use GitHub settings to revoke or rotate it. ### Which PDF option should I use? diff --git a/wiki/Features.md b/wiki/Features.md index a667f82f..ce9b956d 100644 --- a/wiki/Features.md +++ b/wiki/Features.md @@ -241,7 +241,7 @@ Local file import: - Rejects an individual Markdown file larger than 10 MB. - Dragging files over the app shows a compact drop notice. Explorer document drags use a Markdown file preview, folders expand on hover, and the Explorer scrolls near its top and bottom edges. - The first 8 KB of a file are scanned for null bytes to avoid loading binary files as text. -- Imported local files open in the active tab or a new tab depending on the action. +- Imported local files are saved to Explorer without opening new tabs. Select a saved file in Explorer when you want to edit it. Media insertion: @@ -263,19 +263,21 @@ Application feedback: GitHub import: - Accepts `github.com/owner/repo`, `github.com/owner/repo/tree/ref/path`, `github.com/owner/repo/blob/ref/path`, and `raw.githubusercontent.com` file URLs. +- Resolves default branches, explicit branches (including names containing `/`), tags, and commit references to an immutable commit SHA. - Direct Markdown file URLs import immediately. -- Repository or folder URLs query GitHub's public API to find Markdown files. -- The modal shows a tree and supports selecting multiple files. -- Imports create a repository-named folder and reproduce each selected file's nested GitHub directory path inside it. -- Only the first 30 Markdown files are shown if a repository contains more. +- Repository or folder URLs query GitHub's API to find Markdown files. +- The URL step remains a compact 520px dialog. The 760px selection step keeps repository name and metadata on one line, shows a branch/ref only for explicit tree/blob URLs, always links the short commit, and provides a searchable GitHub-style tree with a selected-count badge plus matching borderless controls for select/deselect all and collapse/expand all folders. +- Default-branch imports create a repository-named folder. Each selected file's nested GitHub directory path is reproduced inside it. +- Every Markdown file found is shown. - Requests are rate-limited by the app to avoid hammering GitHub. -- Selected files are fetched as raw content and opened as separate tabs. +- Public files are fetched as raw content. Private files use GitHub's authenticated Contents API. Both are saved to Explorer without opening new tabs. +- Optional private access accepts fine-grained and classic PATs automatically in a compact add-then-select flow. Multiple named tokens remain only in memory for the current session, can have an optional earlier expiry date, and can be removed individually without a passphrase or unlock step. Token results use application toasts instead of inline form status text. Limitations and privacy: - Local file content is read in the browser or desktop application and is not uploaded by local import. -- GitHub import sends repository and path information to GitHub and downloads public file contents from GitHub. -- Private GitHub repositories are not supported because the app does not ask for tokens. +- GitHub import sends repository, ref, and path information to GitHub. Public requests are anonymous unless GitHub requires authenticated access. +- A private-repository PAT is attached only to `api.github.com` requests and is never sent to `raw.githubusercontent.com` or the Markdown Viewer backend. Local deletion does not revoke the token on GitHub. ## Export Markdown to PDF, HTML, PNG, and MD @@ -538,7 +540,7 @@ Security limitations: | Local file import | No | Current tab/workspace | Reads selected files only. | | Managed media upload | Yes, after first-use consent | Cloudflare KV, content-addressed, 90-day TTL | Publicly retrievable by its unguessable HTTPS URL until expiry; still images 300 KiB optimized, GIF 5 MiB, video 10 MiB. | | Markdown/HTML/PDF/PNG export | No, except remote assets already referenced | User download location | Browser may request external images/fonts used by content. | -| GitHub import | Yes | GitHub API/raw URLs | Public repos only; no token flow. | +| GitHub import | Yes | Public: GitHub API/raw URLs. Private: `api.github.com` only | Multiple named fine-grained or classic PATs; session-only in memory with optional earlier expiry and individual removal. | | Emoji lookup | Yes | GitHub emoji API response in memory | Used for shortcode picker/lookup. | | CDN library loading | Yes | Browser/service-worker cache | Web build only, first use unless cached. | | Remote diagram engines | Yes | Third-party renderer response/cache | Source is sent to PlantUML, Kroki, or mermaid.ink depending on renderer/preview. | @@ -552,7 +554,7 @@ Security limitations: - Browser storage quotas can reject very large saved workspaces. - Markdown Viewer does not impose a document-count limit; available storage and operating-system/filesystem constraints still apply. - An individual local Markdown import is limited to 10 MB. -- The GitHub importer shows a maximum of 30 Markdown files. +- The GitHub importer shows every Markdown file found in the selected public or authorized private repository or folder. - Stored Share Snapshot content is limited to 8,000,000 characters. Managed media remains separate and travels as short HTTPS links. - The current Share Snapshot UI does not expose its API deletion token, so UI-created stored snapshots normally remain until their 90-day expiry. - STL source is limited to 2 MiB and parsed geometry to 300,000 vertices. diff --git a/wiki/Home.md b/wiki/Home.md index 5c03b5ce..262d24d6 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -54,7 +54,7 @@ See [Installation](Installation.md) before deploying. The stock Docker image has | Editing | Editor, Split view, Preview, formatting toolbar, custom undo/redo, Find and Replace, LTR/RTL, large-Document rendering paths | | Markdown | CommonMark-style parsing, GFM, tables, tasks, alerts, footnotes, definitions, highlighting, sanitized HTML, and math | | Visual content | Mermaid, PlantUML, Graphviz/DOT, D2, Vega-Lite, WaveDrom, Markmap, GeoJSON, TopoJSON, STL, and ABC | -| Import/export | Local and public GitHub import; Markdown, HTML, Browser Print, legacy raster PDF, and PNG export | +| Import/export | Local and GitHub branch/tag/commit import, with optional encrypted private-repository access; Markdown, HTML, Browser Print, legacy raster PDF, and PNG export | | Sharing | URL-hash or KV-backed Share Snapshot; WebSocket Live Share with host/edit/view capabilities | | Delivery | Static web, PWA, Docker, Cloudflare, and seven Neutralino desktop targets | diff --git a/wiki/Privacy-and-Security.md b/wiki/Privacy-and-Security.md index a39377d5..59bf7aab 100644 --- a/wiki/Privacy-and-Security.md +++ b/wiki/Privacy-and-Security.md @@ -60,7 +60,7 @@ In the desktop application, replacing or deleting the binary does not remove `Do | Feature | Data sent | Destination and retention | Access model | | :--- | :--- | :--- | :--- | | Web/PWA library loading | Library requests, IP and normal HTTP metadata | cdnjs, jsDelivr, or esm.sh; browser/Service Worker cache | Governed by the remote host | -| GitHub import | Repository/path request and selected public content downloads | GitHub API and `raw.githubusercontent.com` | Public repositories only; no token flow | +| GitHub import | Repository/path/ref request and selected content downloads; optional named PATs for private repositories | Public content: GitHub API and `raw.githubusercontent.com`; private content and PAT: `api.github.com` only | Fine-grained and classic PATs are accepted automatically, remain in memory for the current session, support optional earlier in-session expiry, and are never written to application storage | | Emoji lookup | Emoji API request | GitHub API; response kept in memory/cache | Remote host | | Remote diagrams | Diagram source or encoded source | PlantUML, Kroki, or mermaid.ink; response can be browser-cached | Remote renderer receives the source | | GeoJSON/TopoJSON maps | Tile and external-asset requests | Configured map-tile or asset host | Remote host | diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md index d95fa3d8..7251d3ee 100644 --- a/wiki/Troubleshooting.md +++ b/wiki/Troubleshooting.md @@ -78,8 +78,10 @@ Extension matching is case-insensitive. Rename a plain-text file to `.md` only w ## GitHub Import Fails or Shows Too Few Files - Use a public `github.com` repository, tree, blob, or `raw.githubusercontent.com` Markdown URL. -- Private repositories are unsupported because Markdown Viewer does not request a token. -- Repository and folder results show only the first 30 Markdown files. +- For a private repository, add either a fine-grained or classic PAT with permission to read the required repository. The app detects both formats automatically. If an organization requires approval or SAML authorization, complete that step in GitHub. +- Branch, tag, and commit URLs are supported. For branch names containing `/`, paste the complete GitHub tree or blob URL so the importer can resolve the longest matching ref. +- Select the intended named token before importing. If GitHub rejects it or reports that it cannot access the repository, replace the token or update its repository permissions. Check the toast for the specific failure. Tokens are session-only, may expire earlier if you selected an expiry date, and must be added again after reload or app close. +- Repository and folder results show every Markdown file found. - Check GitHub API rate-limit and network responses. - Confirm that CSP allows `api.github.com` and `raw.githubusercontent.com`. - For a direct file, verify that the URL returns text rather than a login or HTML error page. diff --git a/wiki/Usage-Guide.md b/wiki/Usage-Guide.md index b4bf5e05..56eb285a 100644 --- a/wiki/Usage-Guide.md +++ b/wiki/Usage-Guide.md @@ -96,7 +96,7 @@ Scope matching uses Marked's lexer and is best-effort for unusual Markdown. ### Local Files -Use Import > From files, the mobile import button, or drag and drop to open local Markdown files. Dropping a file on an Explorer folder imports it there; dropping it elsewhere imports it at the default workspace root. Folders expand after a short drag hover, and the Explorer scrolls automatically near its top and bottom edges. +Use Import > From files, the mobile import button, or drag and drop to save local Markdown files in Explorer. Imported documents remain closed until you select one in Explorer, so large imports do not fill the tab bar. Dropping a file on an Explorer folder imports it there; dropping it elsewhere imports it at the default workspace root. Folders expand after a short drag hover, and the Explorer scrolls automatically near its top and bottom edges. Paste an image or GIF from the clipboard, drop an image/GIF/video file, or use the media dialog to insert it at the current editor cursor. A progress toast shows preparation and upload status. Still images are optimized, animated GIFs retain their original animation, and MP4, WebM, or Ogg videos are inserted as playable HTML5 media. After first-use consent, the app uploads the file to managed public media storage and inserts a short HTTPS link. Anyone with that unguessable URL can retrieve the media for up to 90 days; after expiry the reference remains in Markdown but the media stops rendering. Documents containing older inline base64 raster images offer to convert them to short links without changing their alt text or title. @@ -116,12 +116,16 @@ Use Import > From GitHub and paste one of these URL types: - `https://github.com/owner/repo` - `https://github.com/owner/repo/tree/main/docs` +- `https://github.com/owner/repo/tree/release/2026/docs` +- `https://github.com/owner/repo/tree/758cbeda07ac15093520c803c11140ae9b1f4a2c` - `https://github.com/owner/repo/blob/main/README.md` - `https://raw.githubusercontent.com/owner/repo/main/README.md` -Direct Markdown file URLs import immediately. Repository and folder URLs query GitHub's public API, show a tree of Markdown files, and let you import selected files into separate tabs. Selected files are placed in a repository-named Explorer folder with their nested GitHub directory paths preserved. If more than 30 Markdown files exist, only the first 30 are shown. +Direct Markdown file URLs import immediately into Explorer. Repository and folder URLs resolve the default branch, an explicit branch (including names containing `/`), a tag, or a commit to an immutable commit SHA before listing files. The searchable, collapsible tree contains every Markdown file found and uses the same compact type scale as other application modals. Repository name and metadata stay on one line: explicit tree/blob URLs show the branch or ref beside the short commit, while a repository URL shows only the short commit. The toolbar places the selected-file count in a compact badge beside matching borderless controls for select/deselect all and collapse/expand all folders. Default-branch imports use a repository-named Explorer folder. Nested GitHub directory paths are preserved, and imported files remain closed until you select one in Explorer. -GitHub import sends the repository/path request to GitHub and only works for public content. The app does not ask for GitHub credentials. +Public repositories need no credentials. For a private repository, expand **Private repository access**, enter a recognizable name and either a fine-grained or classic PAT, and select **Add access**. The app accepts both PAT formats automatically. After adding, the form closes and a dropdown lets you select among multiple named tokens before choosing **Import**. Use the icon controls to add another token or remove the selected one. The importer first tries public access and sends the selected token only to `api.github.com` when authenticated access is needed. Private file content is downloaded through GitHub's Contents API rather than the raw-content host. + +GitHub access tokens are session-only. They remain in memory and are never written to application storage, so they disappear on reload or app close and do not require a protection passphrase or unlock step. You can optionally choose an earlier expiry date; the app removes the token from memory at that time. Adding, removing, expiring, or rejecting a token is reported through the application toast surface. Local removal does not revoke a PAT on GitHub, so revoke it separately in GitHub settings if it may have been exposed. ## Export Markdown to PDF, HTML, PNG, and MD diff --git a/workspace-storage.js b/workspace-storage.js index b7d9b185..20cde308 100644 --- a/workspace-storage.js +++ b/workspace-storage.js @@ -817,6 +817,31 @@ await transactionToPromise(transaction); } + async deleteMetadata(key) { + if (this.desktop) { + if (key === 'vaultSettings') { + this.desktopSettings = {}; + const settingsPath = await this._pathJoin(this.vaultPath, INTERNAL_DIR, 'settings.json'); + if (await this._pathExists(settingsPath)) await Neutralino.filesystem.remove(settingsPath); + return; + } + if (key === 'secretManifest') { + const manifestPath = await this._pathJoin(this.vaultPath, INTERNAL_DIR, 'secret-manifest.json'); + if (await this._pathExists(manifestPath)) await Neutralino.filesystem.remove(manifestPath); + return; + } + if (Neutralino.storage.removeData) { + try { + await Neutralino.storage.removeData('markdownViewerMeta_' + key); + } catch (_) {} + } + return; + } + const transaction = this.db.transaction('metadata', 'readwrite'); + transaction.objectStore('metadata').delete(key); + await transactionToPromise(transaction); + } + async getSecretManifest() { const stored = await this.getMetadata('secretManifest'); if (stored) return stored;