diff --git a/.gitignore b/.gitignore index db400be..e5fa645 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ node_modules yarn.lock dist.zip -dist \ No newline at end of file +dist +*.pem +debug-server.js +package-lock.json \ No newline at end of file diff --git a/src/GitHubAPI/Requestable.js b/src/GitHubAPI/Requestable.js index 5d15d42..b401f42 100644 --- a/src/GitHubAPI/Requestable.js +++ b/src/GitHubAPI/Requestable.js @@ -50,16 +50,26 @@ class Requestable { * @param {string} [AcceptHeader=v3] - the accept header for the requests */ constructor(auth, apiBase, AcceptHeader) { + // [Copilot review] auth is optional per JSDoc — default to {} to prevent + // TypeError when accessing auth.token / auth.username / auth.password + auth = auth || {}; + const normalizedToken = auth.token + ? String(auth.token) + .trim() + .replace(/^Bearer\s+/i, '') + .replace(/^token\s+/i, '') + : auth.token; + this.__apiBase = apiBase || 'https://api.github.com'; this.__auth = { - token: auth.token, + token: normalizedToken, username: auth.username, password: auth.password, }; this.__AcceptHeader = AcceptHeader || 'v3'; - if (auth.token) { - this.__authorizationHeader = 'token ' + auth.token; + if (normalizedToken) { + this.__authorizationHeader = 'token ' + normalizedToken; } else if (auth.username && auth.password) { this.__authorizationHeader = 'Basic ' + Base64.encode(auth.username + ':' + auth.password); @@ -327,11 +337,22 @@ function callbackErrorOrThrow(cb, path) { return function handler(object) { let error; if (object.hasOwnProperty('config')) { - const { - response: { status, statusText }, - config: { method, url }, - } = object; + const response = object.response || {}; + const config = object.config || {}; + const status = response.status; + const statusText = response.statusText || ''; + const method = config.method || 'UNKNOWN'; + const url = config.url || 'UNKNOWN_URL'; + const ghMessage = response.data && response.data.message ? response.data.message : ''; + const docsUrl = response.data && response.data.documentation_url + ? ` (${response.data.documentation_url})` + : ''; + let message = `${status} error making request ${method} ${url}: "${statusText}"`; + if (ghMessage) { + message += ` | GitHub: ${ghMessage}${docsUrl}`; + } + error = new ResponseError(message, path, object); log(`${message} ${JSON.stringify(object.data)}`); } else { diff --git a/src/githubFs.js b/src/githubFs.js index 5220994..bb14958 100644 --- a/src/githubFs.js +++ b/src/githubFs.js @@ -10,9 +10,33 @@ const prompt = acode.require('prompt'); const encodings = acode.require('encodings'); const test = (url) => /^gh:/.test(url); +const REGISTRY_KEY = '__acodeGithubFsTests__'; + +function _isZh() { + try { + const langs = [].concat(navigator.languages || [], navigator.language || []); + return langs.some((l) => /^zh(?:-|$)/i.test(String(l || ''))); + } catch (_) { return false; } +} +function _t(en, zh) { return _isZh() ? zh : en; } + +function getRegistry() { + if (!window[REGISTRY_KEY]) { + window[REGISTRY_KEY] = []; + } + return window[REGISTRY_KEY]; +} + +function removeAllGithubFsHandlers() { + const registry = getRegistry(); + registry.forEach((registeredTest) => { + try { fsOperation.remove(registeredTest); } catch (_) {} + }); + registry.length = 0; +} githubFs.remove = () => { - fsOperation.remove(test); + removeAllGithubFsHandlers(); }; /** @@ -41,6 +65,10 @@ githubFs.constructUrl = (type, user, repo, path, branch) => { }; export default function githubFs(token, settings) { + // Ensure only one active gh:// handler even after plugin hot-reloads. + removeAllGithubFsHandlers(); + getRegistry().push(test); + fsOperation.extend(test, (url) => { const { user, type, repo, path, gist } = parseUrl(url); if (type === 'repo') { @@ -92,9 +120,9 @@ export default function githubFs(token, settings) { */ async function getCommitMessage(message) { if (settings.askCommitMessage) { - const res = await prompt('Commit message', message, 'text'); + const res = await prompt(_t('Commit message', '提交信息'), message, 'text'); if (!res) { - const error = new Error('Commit aborted'); + const error = new Error(_t('Commit aborted', '提交已取消')); error.code = 0; error.toString = () => error.message; throw error; diff --git a/src/main.js b/src/main.js index 1129cb6..da3500c 100644 --- a/src/main.js +++ b/src/main.js @@ -1,6 +1,7 @@ import GitHub from './GitHubAPI/GitHub'; import plugin from '../plugin.json'; import githubFs from './githubFs'; +import createSidebar from './sidebar'; const prompt = acode.require('prompt'); const confirm = acode.require('confirm'); @@ -13,6 +14,19 @@ const appSettings = acode.require('settings'); const toast = acode.require('toast'); const fsOperation = acode.require('fsOperation'); +function isChineseLocale() { + const langs = []; + try { + if (Array.isArray(navigator.languages)) langs.push(...navigator.languages); + if (navigator.language) langs.push(navigator.language); + } catch (_) {} + return langs.some((lang) => /^zh(?:-|$)/i.test(String(lang || ''))); +} + +function t(en, zh) { + return isChineseLocale() ? zh : en; +} + if (!Blob.prototype.arrayBuffer) { Blob.prototype.arrayBuffer = function () { return new Promise((resolve, reject) => { @@ -30,6 +44,8 @@ class AcodePlugin { #fsInitialized = false; #repos = []; #gists = []; + #sidebar = null; + #tokenPromise = null; async init() { this.commands.forEach(command => { @@ -39,10 +55,21 @@ class AcodePlugin { this.token = localStorage.getItem('github-token'); await this.initFs(); + // Register sidebar + try { + this.#sidebar = createSidebar(this); + this.#sidebar?.register(); + } catch (e) { + console.warn('GitHub sidebar registration failed:', e); + } + tutorial(plugin.id, (hide) => { const commands = editorManager.editor.commands.byName; const openCommandPalette = commands.openCommandPalette || commands.openCommandPallete; - const message = "Github plugin is installed successfully, open command palette and search 'open repository' to open a github repository."; + const message = t( + "GitHub plugin is installed successfully. You can use the sidebar panel or open command palette and search 'open repository' to open a GitHub repository.", + 'GitHub 插件安装成功。您可以使用侧边栏面板,或打开命令面板搜索「open repository」来打开 GitHub 仓库。' + ); let key = 'Ctrl+Shift+P'; if (openCommandPalette) { key = openCommandPalette.bindKey.win; @@ -55,10 +82,19 @@ class AcodePlugin { new EditorFile(fileInfo.name, { uri: KEYBINDING_FILE, render: true }); hide(); }; - return
{message} Shortcut to open command pallete is not set, Click here set shortcut or use '...' icon in quick tools.
+ return{message} {t( + "Shortcut to open command palette is not set,", + '打开命令面板的快捷键尚未设置,' + )} {t('Click here', '点击这里')} {t( + "set shortcut or use '...' icon in quick tools.", + "设置快捷键,或使用快捷工具栏中的 '...' 图标。" + )}
} - return{message} To open command palette use combination {key} or use '...' icon in quick tools.
; + return{message} {t( + `To open command palette use combination ${key} or use '...' icon in quick tools.`, + `打开命令面板使用组合键 ${key},或使用快捷工具栏中的 '...' 图标。` + )}
; }); } @@ -71,34 +107,62 @@ class AcodePlugin { async getToken() { if (this.token) return this.token; - await this.updateToken(); + // Guard against concurrent calls triggering multiple prompts + if (!this.#tokenPromise) { + this.#tokenPromise = this.updateToken().finally(() => { + this.#tokenPromise = null; + }); + } + await this.#tokenPromise; return this.token; } async destroy() { + this.#sidebar?.unregister(); + this.#sidebar = null; githubFs.remove(); this.commands.forEach(command => { editorManager.editor.commands.removeCommand(command.name); }); } + clearCache() { + this.#repos = []; + this.#gists = []; + } + async openRepo() { await this.initFs(); this.token = await this.getToken(); + + const repos = await this.listRepositories(); + if (!repos.length) { + toast(t('No repositories found', '未找到仓库')); + return; + } + palette( - this.listRepositories.bind(this), + () => repos, this.selectBranch.bind(this), - 'Type to search repository', + t('Type to search repository', '输入以搜索仓库'), ); } async selectBranch(repo) { const [user, repoName] = repo.split('/'); + + toast(t('Loading branches...', '正在查找分支...')); + const branches = await this.listBranches(user, repoName); + if (!branches || !branches.length) { + toast(t('No branches found', '未找到分支')); + return; + } + palette( - this.listBranches.bind(this, user, repoName), + () => branches, (branch) => this.openRepoAsFolder(user, repoName, branch) .catch(helpers.error), - 'Type to search branch', + t('Type to search branch', '输入以搜索分支'), ); } @@ -108,17 +172,17 @@ class AcodePlugin { palette( this.listGists.bind(this, false), resolve, - 'Type to search gist', + t('Type to search gist', '输入以搜索 Gist'), ); }); - const confirmation = await confirm(strings['warning'], 'Delete this gist?'); + const confirmation = await confirm(strings['warning'], t('Delete this gist?', '删除此 Gist?')); if (!confirmation) return; const gh = await this.#GitHub(); const gistApi = gh.getGist(gist); await gistApi.delete(); this.#gists = this.#gists.filter(g => g.id !== gist); - window.toast('Gist deleted'); + window.toast(t('Gist deleted', 'Gist 已删除')); } async deleteGistFile() { @@ -127,7 +191,7 @@ class AcodePlugin { palette( this.listGists.bind(this, false), resolve, - 'Type to search gist', + t('Type to search gist', '输入以搜索 Gist'), ); }); @@ -135,11 +199,11 @@ class AcodePlugin { palette( this.listGistFiles.bind(this, gist, false), resolve, - 'Type to search file', + t('Type to search gist file', '输入以搜索 Gist 文件'), ); }); - const confirmation = await confirm(strings['warning'], 'Delete this file?'); + const confirmation = await confirm(strings['warning'], t('Delete this file?', '删除此文件?')); if (!confirmation) return; const gh = await this.#GitHub(); @@ -151,7 +215,7 @@ class AcodePlugin { }); const cachedGist = this.#getGist(gist); if (cachedGist) cachedGist.files = cachedGist.files.filter(f => f.filename !== file); - window.toast('File deleted'); + window.toast(t('File deleted', '文件已删除')); } async openRepoAsFolder(user, repoName, branch) { @@ -188,8 +252,13 @@ class AcodePlugin { const url = githubFs.constructUrl('repo', user, repoName, '/', branch); openFolder(url, { name: `${user}/${repoName}/${branch}`, + listFiles: false, saveState: false, }); + toast(t( + `Repo opened: ${user}/${repoName}/${branch}. Check the file browser sidebar to browse files.`, + `仓库已打开:${user}/${repoName}/${branch}。请在文件浏览器侧边栏中查看文件。` + )); } async openGist() { @@ -199,7 +268,7 @@ class AcodePlugin { palette( this.listGists.bind(this), this.openGistFile.bind(this), - 'Type to search gist', + t('Type to search gist', '输入以搜索 Gist'), ); } @@ -208,32 +277,32 @@ class AcodePlugin { let thisFilename; if (gist === this.NEW) { const { description, name, public: isPublic } = await multiPrompt( - 'New gist', + t('New gist', '新建 Gist'), [{ id: 'description', - placeholder: 'Description', + placeholder: t('Description', '描述'), type: 'text', }, { id: 'name', - placeholder: 'File name*', + placeholder: t('File name*', '文件名*'), type: 'text', required: true, }, [ - 'Visibility', + t('Visibility', '可见性'), { id: 'public', name: 'visibility', value: true, - placeholder: 'Public', + placeholder: t('Public', '公开'), type: 'radio', }, { id: 'private', name: 'visibility', value: false, - placeholder: 'Private', + placeholder: t('Private', '私有'), type: 'radio', } ]], @@ -263,9 +332,9 @@ class AcodePlugin { this.listGistFiles.bind(this, gist), async (file) => { if (file === this.NEW) { - const filename = await prompt('Enter file name', '', 'text', { + const filename = await prompt(t('Enter file name', '输入文件名'), '', 'text', { required: true, - placeholder: 'filename', + placeholder: t('filename', '文件名'), }); if (!filename) { window.toast(strings['cancelled']); @@ -295,7 +364,7 @@ class AcodePlugin { thisFilename = file; resolve(); }, - 'Type to search gist file', + t('Type to search gist file', '输入以搜索 Gist 文件'), ); }); } @@ -308,16 +377,55 @@ class AcodePlugin { } async updateToken() { - const result = await prompt('Enter github token', '', 'text', { + const result = await prompt( + t( + 'Enter GitHub token (minimum: classic `repo` + `gist`, or fine-grained Metadata:Read, Contents:Read/Write, Gists:Read/Write)', + '输入 GitHub 令牌(最低权限:Classic 需 repo + gist;Fine-grained 需 Metadata:Read、Contents:Read/Write、Gists:Read/Write)' + ), + '', + 'text', + { required: true, - placeholder: 'token', - }); + placeholder: t('token', '令牌'), + } + ); if (result) { - this.token = result; + const normalizedToken = String(result) + .trim() + .replace(/^Bearer\s+/i, '') + .replace(/^token\s+/i, ''); + + if (!/^[A-Za-z0-9_]+$/.test(normalizedToken)) { + toast(t( + 'Invalid token format: only letters, numbers, underscore are allowed', + '令牌格式无效:仅允许字母、数字、下划线' + )); + return; + } + + // [Copilot+Greptile review] Persist to localStorage only after + // validation succeeds; rollback this.token on failure so an invalid + // token never stays cached across app restarts. + const oldToken = this.token; + this.token = normalizedToken; this.#fsInitialized = false; - localStorage.setItem('github-token', result); await this.initFs(); + + try { + const gh = await this.#GitHub(); + await gh.getUser().getProfile(); + // Validation passed — now persist + localStorage.setItem('github-token', this.token); + } catch (error) { + // Rollback to previous token + this.token = oldToken; + this.#fsInitialized = false; + await this.initFs(); + const msg = error && (error.message || String(error)) || t('Unknown token error', '未知令牌错误'); + toast(t('Token validation failed: ', '令牌校验失败:') + msg); + throw error; + } } } @@ -368,7 +476,7 @@ class AcodePlugin { } list.push({ - text: 'New branch', + text: t('New branch', '新建分支'), value: this.NEW, }); @@ -397,7 +505,7 @@ class AcodePlugin { if (showAddNew) { list.push({ - text: this.#highlightedText('New gist'), + text: this.#highlightedText(t('New gist', '新建 Gist')), value: this.NEW, }); } @@ -429,7 +537,7 @@ class AcodePlugin { if (showAddNew) { list.push({ - text: this.#highlightedText('New file'), + text: this.#highlightedText(t('New file', '新建文件')), value: this.NEW, }); } @@ -468,36 +576,33 @@ class AcodePlugin { return [ { name: 'github:repository:selectrepo', - description: 'Open repository', + description: t('Open repository', '\u6253\u5f00\u4ed3\u5e93'), exec: this.openRepo.bind(this), }, { name: 'github:gist:opengist', - description: 'Open gist', + description: t('Open gist', '\u6253\u5f00 Gist'), exec: this.openGist.bind(this), }, { name: 'github:gist:deletegist', - description: 'Delete gist', + description: t('Delete gist', '\u5220\u9664 Gist'), exec: this.deleteGist.bind(this), }, { name: 'github:gist:deletegistfile', - description: 'Delete gist file', + description: t('Delete gist file', '\u5220\u9664 Gist \u6587\u4ef6'), exec: this.deleteGistFile.bind(this), }, { name: 'github:updatetoken', - description: 'Update github token', + description: t('Update github token', '\u66f4\u65b0 GitHub \u4ee4\u724c'), exec: this.updateToken.bind(this), }, { name: 'github:clearcache', - description: 'Clear github cache', - exec: () => { - this.#repos = []; - this.#gists = []; - } + description: t('Clear github cache', '\u6e05\u9664 GitHub \u7f13\u5b58'), + exec: this.clearCache.bind(this), } ] } @@ -517,7 +622,7 @@ class AcodePlugin { const list = [ { key: 'askCommitMessage', - text: 'Ask for commit message', + text: t('Ask for commit message', '提交时询问提交信息'), checkbox: this.settings.askCommitMessage, } ]; @@ -551,8 +656,10 @@ function tutorial(id, message) { } if (window.acode) { + // plugin setup const acodePlugin = new AcodePlugin(); acode.setPluginInit(plugin.id, async (baseUrl, $page, { cacheFileUrl, cacheFile }) => { + // pluginInit if (!baseUrl.endsWith('/')) { baseUrl += '/'; } diff --git a/src/sidebar.js b/src/sidebar.js new file mode 100644 index 0000000..4e0d929 --- /dev/null +++ b/src/sidebar.js @@ -0,0 +1,223 @@ +import plugin from '../plugin.json'; + +var SIDEBAR_ID = plugin.id + '.sidebar'; + +function esc(s) { + return String(s).replace(/&/g,'&').replace(//g,'>'); +} + +function isChineseLocale() { + var langs = []; + try { + if (Array.isArray(navigator.languages)) langs = langs.concat(navigator.languages); + if (navigator.language) langs.push(navigator.language); + } catch (_) {} + + for (var i = 0; i < langs.length; i++) { + if (/^zh(?:-|$)/i.test(String(langs[i] || ''))) return true; + } + return false; +} + +function getI18n() { + if (isChineseLocale()) { + return { + title: 'GitHub', + token: '令牌', + tokenSet: '已设置', + tokenNotSet: '未设置', + status: '状态', + idle: '空闲', + running: '执行中…', + repository: '仓库', + openRepository: '打开仓库', + gist: 'Gist', + openGist: '打开 Gist', + deleteGist: '删除 Gist', + deleteGistFile: '删除 Gist 文件', + accountCache: '账号 / 缓存', + updateToken: '更新令牌', + clearCache: '清空缓存', + actionRunning: 'GitHub 操作正在执行', + actionUnavailable: '操作不可用', + actionFailed: '执行失败', + cacheCleared: '缓存已清空', + tokenUpdated: '令牌已更新' + }; + } + + return { + title: 'GitHub', + token: 'Token', + tokenSet: 'SET', + tokenNotSet: 'NOT SET', + status: 'Status', + idle: 'idle', + running: 'running…', + repository: 'Repository', + openRepository: 'Open Repository', + gist: 'Gist', + openGist: 'Open Gist', + deleteGist: 'Delete Gist', + deleteGistFile: 'Delete Gist File', + accountCache: 'Account / Cache', + updateToken: 'Update Token', + clearCache: 'Clear Cache', + actionRunning: 'GitHub action is running', + actionUnavailable: 'Action unavailable', + actionFailed: 'Action failed', + cacheCleared: 'Cache cleared', + tokenUpdated: 'Token updated' + }; +} + +export default function createSidebar(pluginInstance) { + var sidebarApps; + var toast; + var t = getI18n(); + try { + sidebarApps = acode.require('sidebarApps'); + toast = acode.require('toast'); + } catch (e) { + return null; + } + if (!sidebarApps) return null; + + var container = null; + var busy = false; + + function init(el) { + container = el; + showPanel(); + } + + function notify(message) { + try { + if (toast) toast(message); + else if (window.toast) window.toast(message); + } catch (_) {} + } + + // [Copilot review] Removed unused `refreshAfter` parameter — showPanel() + // is already called unconditionally in .finally(), so the panel always + // refreshes after every action regardless. + function runAction(methodName, title) { + if (busy) { + notify(t.actionRunning); + return; + } + + var action = pluginInstance && pluginInstance[methodName]; + if (typeof action !== 'function') { + notify(t.actionUnavailable + ': ' + title); + return; + } + + busy = true; + showPanel(); + + Promise.resolve() + .then(function () { return action.call(pluginInstance); }) + .then(function () { + if (methodName === 'clearCache') notify(t.cacheCleared); + if (methodName === 'updateToken') notify(t.tokenUpdated); + }) + .catch(function (error) { + var msg = error && (error.message || String(error)) || 'Unknown error'; + notify(t.actionFailed + ': ' + title + ' - ' + msg); + }) + .finally(function () { + busy = false; + showPanel(); + }); + } + + function createActionButton(text, methodName) { + var button = document.createElement('button'); + button.textContent = text; + button.disabled = busy; + button.style.cssText = [ + 'display:block', + 'width:100%', + 'margin:6px 0', + 'padding:7px 10px', + 'text-align:left', + 'font-size:12px', + 'background:#333', + 'color:#ddd', + 'border:1px solid #555', + 'border-radius:4px', + 'opacity:' + (busy ? '0.7' : '1') + ].join(';'); + button.onclick = function () { + runAction(methodName, text); + }; + return button; + } + + function showPanel() { + if (!container) return; + container.innerHTML = ''; + var div = document.createElement('div'); + div.style.cssText = 'padding:8px;font-size:12px;color:#ccc;word-break:break-all;font-family:monospace;line-height:1.5'; + + var tokenState = pluginInstance && pluginInstance.token ? t.tokenSet : t.tokenNotSet; + var header = document.createElement('div'); + header.innerHTML = '' + t.title + '
' + + '' + t.token + ': ' + esc(tokenState) + '
' + + '' + t.status + ': ' + (busy ? t.running : t.idle) + '
' + + '