diff --git a/src/main/server/file.ts b/src/main/server/file.ts index 1e7be5f5..1ef76ca7 100644 --- a/src/main/server/file.ts +++ b/src/main/server/file.ts @@ -164,6 +164,21 @@ export function stat (repo: string, p: string) { }, p) } +export function checkWriteable (repo: string, p: string) { + return withRepo(repo, async (_, targetPath) => { + if (readonly) { + return false + } + + try { + await fs.access(targetPath, fs.constants.W_OK) + return true + } catch (error) { + return false + } + }, p) +} + export function write (repo: string, p: string, content: any): Promise { if (readonly) throw new Error('Readonly') diff --git a/src/main/server/index.ts b/src/main/server/index.ts index 5f182c7f..dda4fa38 100644 --- a/src/main/server/index.ts +++ b/src/main/server/index.ts @@ -22,6 +22,7 @@ import config from '../config' import * as jwt from '../jwt' import { getAction } from '../action' import * as extension from '../extension' +import type { FileReadResult } from '../../share/types' const isLocalhost = (address: string) => { return ip.isEqual(address, '127.0.0.1') || ip.isEqual(address, '::1') @@ -122,11 +123,14 @@ const fileContent = async (ctx: any, next: any) => { const content = await file.read(repo, path) - ctx.body = result('ok', 'success', { + const data: FileReadResult = { content: content.toString(asBase64 ? 'base64' : undefined), hash: await file.hash(repo, path), - stat: await file.stat(repo, path) - }) + stat: await file.stat(repo, path), + writeable: await file.checkWriteable(repo, path), + } + + ctx.body = result('ok', 'success', data) } else if (ctx.method === 'POST') { const { oldHash, content, asBase64, repo, path } = ctx.request.body diff --git a/src/renderer/components/DefaultEditor.vue b/src/renderer/components/DefaultEditor.vue index e452b34c..6432cf21 100644 --- a/src/renderer/components/DefaultEditor.vue +++ b/src/renderer/components/DefaultEditor.vue @@ -110,7 +110,7 @@ export default defineComponent({ await nextTick() getEditor().updateOptions({ - readOnly: FLAG_READONLY || !current || !current.plain + readOnly: FLAG_READONLY || !current || !current.plain || current.writeable === false }) if (isDefault()) { diff --git a/src/renderer/components/FileTabs.vue b/src/renderer/components/FileTabs.vue index 10451d6b..68946836 100644 --- a/src/renderer/components/FileTabs.vue +++ b/src/renderer/components/FileTabs.vue @@ -338,11 +338,13 @@ export default defineComponent({ const fileTabs = computed(() => (tabs.value as Components.FileTabs.Item[]).map(tab => { if (currentFile.value && tab.key === toUri(currentFile.value)) { - const status = currentFile.value.status + const { status, writeable } = currentFile.value let mark = '' if (!isSaved.value) { mark = '*' + } else if (writeable === false) { + mark = '🔒' } else if (status === 'saved') { mark = '' } else if (status === 'save-failed') { diff --git a/src/renderer/plugins/editor-markdown.ts b/src/renderer/plugins/editor-markdown.ts index abb3fa36..ce8ba514 100644 --- a/src/renderer/plugins/editor-markdown.ts +++ b/src/renderer/plugins/editor-markdown.ts @@ -3,6 +3,7 @@ import dayjs from 'dayjs' import { getEditor, getOneIndent, insert, whenEditorReady } from '@fe/services/editor' import type { Plugin } from '@fe/context' import { t } from '@fe/services/i18n' +import type { IMarkdownString } from 'monaco-editor' export default { name: 'editor-markdown', @@ -37,6 +38,8 @@ export default { const idRevealLineInPreview = 'plugin.editor.reveal-line-in-preview' const idForceInsertNewLine = 'plugin.editor.force-insert-new-line' const idForceInsertIndent = 'plugin.editor.force-insert-indent' + const idRevealCurrentFileInOS = 'plugin.editor.reveal-current-file-in-os' + const idRefreshCurrentDoc = 'plugin.editor.refresh-current-document' whenEditorReady().then(({ editor, monaco }) => { const KM = monaco.KeyMod @@ -110,6 +113,44 @@ export default { editor.onDidCompositionEnd(() => { ctx.store.state.inComposition = false }) + + const messageContribution: any = editor.getContribution('editor.contrib.messageController') + editor.onDidAttemptReadOnlyEdit(() => { + const currentFile = ctx.store.state.currentFile + const cmdRevealCurrentFile = `command:vs.editor.ICodeEditor:1:${idRevealCurrentFileInOS}` + const cmdRefreshCurrentDoc = `command:vs.editor.ICodeEditor:1:${idRefreshCurrentDoc}` + const message = + ctx.args.FLAG_READONLY + ? ctx.i18n.t('read-only-mode-desc') + : !currentFile + ? ctx.i18n.t('file-status.no-file') + : currentFile.writeable === false + ? { + value: ctx.i18n.t('file-readonly-desc', cmdRevealCurrentFile, cmdRefreshCurrentDoc), + isTrusted: true, + } as IMarkdownString + : ctx.i18n.t('can-not-edit-this-file-type') + messageContribution.showMessage(message, editor.getPosition()) + }) + + editor.addAction({ + id: idRevealCurrentFileInOS, + label: ctx.i18n.t('editor.action-label.reveal-current-file-in-os'), + run () { + const filePath = ctx.store.state.currentFile?.absolutePath + if (filePath) { + ctx.base.showItemInFolder(filePath) + } + } + }) + + editor.addAction({ + id: idRefreshCurrentDoc, + label: ctx.i18n.t('editor.action-label.refresh-current-document'), + run () { + ctx.view.refresh() + } + }) }) ctx.statusBar.tapMenus(menus => { diff --git a/src/renderer/services/document.ts b/src/renderer/services/document.ts index 30648aa1..3e4ae8e3 100644 --- a/src/renderer/services/document.ts +++ b/src/renderer/services/document.ts @@ -10,7 +10,7 @@ import * as crypto from '@fe/utils/crypto' import { useModal } from '@fe/support/ui/modal' import { useToast } from '@fe/support/ui/toast' import store from '@fe/support/store' -import { DocType, type Doc, type DocCategory, type PathItem, type SwitchDocOpts } from '@fe/types' +import type { DocType, FileStat, Doc, DocCategory, PathItem, SwitchDocOpts } from '@fe/types' import { basename, dirname, extname, isBelongTo, join, normalizeSep, relative, resolve } from '@fe/utils/path' import { getActionHandler } from '@fe/core/action' import { triggerHook } from '@fe/core/hook' @@ -717,7 +717,8 @@ async function _switchDoc (doc: Doc | null, opts?: SwitchDocOpts): Promise let content = '' let hash = '' - let stat + let stat: FileStat | undefined + let writeable: boolean | undefined if (doc.plain) { const timer = setTimeout(() => { store.state.currentFile = { ...doc!, status: undefined } @@ -730,6 +731,7 @@ async function _switchDoc (doc: Doc | null, opts?: SwitchDocOpts): Promise content = res.content hash = res.hash stat = res.stat + writeable = res.writeable } // decrypt content. @@ -744,6 +746,7 @@ async function _switchDoc (doc: Doc | null, opts?: SwitchDocOpts): Promise store.state.currentFile = { ...doc, stat, + writeable, content, passwordHash, contentHash: hash, diff --git a/src/renderer/services/editor.ts b/src/renderer/services/editor.ts index 08cd64a6..198a25e4 100644 --- a/src/renderer/services/editor.ts +++ b/src/renderer/services/editor.ts @@ -1,6 +1,5 @@ import type * as Monaco from 'monaco-editor' import { cloneDeep, debounce } from 'lodash-es' -import { FLAG_READONLY } from '@fe/support/args' import { isElectron, isMacOS } from '@fe/support/env' import { registerHook, triggerHook } from '@fe/core/hook' import { getActionHandler, registerAction } from '@fe/core/action' @@ -79,7 +78,6 @@ export const getDefaultOptions = (): Monaco.editor.IStandaloneEditorConstruction vertical: 'hidden', verticalScrollbarSize: 0 } : undefined, - readOnly: FLAG_READONLY, acceptSuggestionOnEnter: 'smart', unicodeHighlight: { ambiguousCharacters: false, diff --git a/src/renderer/support/api.ts b/src/renderer/support/api.ts index d5507a55..c7edca68 100644 --- a/src/renderer/support/api.ts +++ b/src/renderer/support/api.ts @@ -1,7 +1,7 @@ import type { Stats } from 'fs' import type { WatchOptions } from 'chokidar' import type { IProgressMessage, ISerializedFileMatch, ISerializedSearchSuccess, ITextQuery } from 'ripgrep-wrapper' -import type { Components, Doc, ExportType, FileItem, FileSort, FileStat, PathItem } from '@fe/types' +import type { Components, Doc, ExportType, FileItem, FileReadResult, FileSort, FileStat, PathItem } from '@fe/types' import { isElectron } from '@fe/support/env' import { HELP_REPO_NAME, JWT_TOKEN } from './args' @@ -92,7 +92,7 @@ export async function proxyFetch (url: string, init?: Omit */ async function fetchHelpContent (docName: string) { const result = await fetchHttp('/api/help?doc=' + docName) - return { content: result.data.content, hash: '', stat: { mtime: 0, birthtime: 0, size: 0 } } + return { content: result.data.content, hash: '', stat: { mtime: 0, birthtime: 0, size: 0 }, writeable: true } } /** @@ -101,7 +101,7 @@ async function fetchHelpContent (docName: string) { * @param asBase64 * @returns */ -export async function readFile (file: PathItem, asBase64 = false): Promise<{content: string, hash: string, stat: FileStat}> { +export async function readFile (file: PathItem, asBase64 = false): Promise { const { path, repo } = file if (repo === HELP_REPO_NAME) { diff --git a/src/share/i18n/languages/en.ts b/src/share/i18n/languages/en.ts index a27b2b20..438d4f65 100644 --- a/src/share/i18n/languages/en.ts +++ b/src/share/i18n/languages/en.ts @@ -41,6 +41,9 @@ const data = { 'switch-repo-n': 'Switch to the repository [%s]', 'switch-the-last-repo': 'Switch to the last repository', 'edit-or-generate-text-using-ai': 'Edit or generate text using AI', + 'file-readonly-desc': 'The file is read-only. You can [change to writable](%s) in the system and then [refresh](%s) the document to edit.', + 'can-not-edit-this-file-type': 'Cannot edit this file type', + 'read-only-mode-desc': 'The application is currently in read-only mode and cannot be edited.', 'premium': { 'confetti': 'Confetti', 'need-purchase': '[%s] Premium is required', @@ -484,6 +487,10 @@ const data = { }, 'switch-editor': 'Switch Editor', 'default-editor': 'Default Editor', + 'action-label': { + 'reveal-current-file-in-os': 'Reveal Current File in OS', + 'refresh-current-document': 'Refresh Current Document', + }, }, 'previewer': { 'default-previewer': 'Default Previewer', diff --git a/src/share/i18n/languages/zh-CN.ts b/src/share/i18n/languages/zh-CN.ts index f2936a47..fc9cbaca 100644 --- a/src/share/i18n/languages/zh-CN.ts +++ b/src/share/i18n/languages/zh-CN.ts @@ -42,6 +42,9 @@ const data: BaseLanguage = { 'switch-repo-n': '切换到仓库 %s', 'switch-the-last-repo': '切换到最后一个仓库', 'edit-or-generate-text-using-ai': '使用 AI Copilot 编辑或生成文字', + 'file-readonly-desc': '当前文件只读,如需编辑请先在系统中[改为可写](%s),然后[刷新](%s)当前文档。', + 'can-not-edit-this-file-type': '不能编辑此类型文件', + 'read-only-mode-desc': '当前是只读模式,不可编辑', 'premium': { 'confetti': '彩色纸屑', 'need-purchase': '[%s] 需要高级版', @@ -485,6 +488,10 @@ const data: BaseLanguage = { }, 'switch-editor': '切换编辑器', 'default-editor': '默认编辑器', + 'action-label': { + 'reveal-current-file-in-os': '在系统中显示当前文件', + 'refresh-current-document': '刷新当前文档', + }, }, 'previewer': { 'default-previewer': '默认预览器', diff --git a/src/share/i18n/languages/zh-TW.ts b/src/share/i18n/languages/zh-TW.ts index 79d20786..8187ce47 100644 --- a/src/share/i18n/languages/zh-TW.ts +++ b/src/share/i18n/languages/zh-TW.ts @@ -42,6 +42,9 @@ const data: BaseLanguage = { 'switch-repo-n': '切換到仓库 %s', 'switch-the-last-repo': '切換到最後一個仓库', 'edit-or-generate-text-using-ai': '使用 AI Copilot 編輯或生成文本', + 'file-readonly-desc': '當前文件是唯讀的。如需編輯,請先在系統中[更改為可寫](%s),然後[刷新](%s)當前文檔。', + 'can-not-edit-this-file-type': '不能編輯此類型的文件', + 'read-only-mode-desc': '當前應用處於唯讀模式,不可編輯', 'premium': { 'confetti': '彩色紙屑', 'need-purchase': '[%s] 需高級版', @@ -485,6 +488,10 @@ const data: BaseLanguage = { }, 'switch-editor': '切換編輯器', 'default-editor': '默認編輯器', + 'action-label': { + 'reveal-current-file-in-os': '在系統中顯示當前文件', + 'refresh-current-document': '刷新當前文件', + } }, 'previewer': { 'default-previewer': '預設預覽器', diff --git a/src/share/types.ts b/src/share/types.ts index e5599c9e..c074a20e 100644 --- a/src/share/types.ts +++ b/src/share/types.ts @@ -11,6 +11,13 @@ export interface FileStat { size: number, } +export interface FileReadResult { + content: string, + stat: FileStat, + hash: string, + writeable: boolean, +} + export interface Doc extends PathItem { type: 'file' | 'dir'; name: string; @@ -19,6 +26,7 @@ export interface Doc extends PathItem { passwordHash?: string; contentHash?: string; stat?: FileStat, + writeable?: boolean, status?: 'loaded' | 'save-failed' | 'saved' | 'unsaved'; absolutePath?: string, plain?: boolean;