diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f6e229a..55ac992 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -82,8 +82,8 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: mds-macos - path: dist/mds-macos.zip + name: markdown-editor-macos + path: dist/markdown-editor-macos.zip retention-days: 90 build-windows: @@ -124,8 +124,8 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: mds-windows - path: dist/mds-windows.zip + name: markdown-editor-windows + path: dist/markdown-editor-windows.zip retention-days: 90 deploy-pages: @@ -166,6 +166,12 @@ jobs: - name: Setup Pages uses: actions/configure-pages@v4 + - name: Delete old pages artifact + uses: geekyeggo/delete-artifact@v5 + with: + name: github-pages + failOnError: false + - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: @@ -186,13 +192,13 @@ jobs: - name: Download macOS artifact uses: actions/download-artifact@v4 with: - name: mds-macos + name: markdown-editor-macos path: dist/ - name: Download Windows artifact uses: actions/download-artifact@v4 with: - name: mds-windows + name: markdown-editor-windows path: dist/ - name: Get version @@ -205,8 +211,8 @@ jobs: uses: softprops/action-gh-release@v1 with: files: | - dist/mds-macos.zip - dist/mds-windows.zip + dist/markdown-editor-macos.zip + dist/markdown-editor-windows.zip tag_name: ${{ steps.version.outputs.version }} name: Release ${{ steps.version.outputs.version }} draft: false diff --git a/.gitignore b/.gitignore index 43f6850..bd9727c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ yarn-debug.log* yarn-error.log* dist +dist-local config.json @@ -86,5 +87,6 @@ pids report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json editor-settings.json -MDS-Server.app -mds-macos.zip +Markdown-Editor.app +markdown-editor-macos.zip +markdown-editor-windows.zip diff --git a/README.md b/README.md index 71a11fd..b1ceaa1 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,24 @@ A web-based WYSIWYG markdown editor that works with your local files. Simply specify the root path of your documents to start editing. +Built with [Milkdown](https://milkdown.dev/getting-started) and [React CodeMirror](https://uiwjs.github.io/react-codemirror/) for editing and displaying local markdown files. + +## Quick Start + **🌐 [Try it online](https://s-elo.github.io/Markdown-editor)** -Built with [Milkdown](https://milkdown.dev/getting-started) and [React CodeMirror](https://uiwjs.github.io/react-codemirror/) for editing and displaying local markdown files. +### Download + +1. Download from the [latest release artifact](https://github.com/s-elo/Markdown-editor/releases). + +Download the `markdown-editor-macos.zip` for `MacOs`, `markdown-editor-windows.zip` for `Windows`. + +2. Unzip the file and run the binary file. + +> [!TIP] +> For MacOS, rignt click to open the App. +> +> For windows, right click to run the executable as **administrator**. ## Key Features diff --git a/client/package.json b/client/package.json index 2937be8..46b4490 100644 --- a/client/package.json +++ b/client/package.json @@ -15,10 +15,10 @@ "@emotion/css": "11.13.5", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@milkdown/crepe": "7.18.0", - "@milkdown/kit": "7.18.0", - "@milkdown/react": "7.18.0", - "@milkdown/utils": "7.18.0", + "@milkdown/crepe": "7.19.0", + "@milkdown/kit": "7.19.0", + "@milkdown/react": "7.19.0", + "@milkdown/utils": "7.19.0", "@mui/icons-material": "^7.2.0", "@reduxjs/toolkit": "^1.7.1", "@uiw/codemirror-theme-github": "^4.24.2", @@ -58,4 +58,4 @@ "@types/react-redux": "7.1.34", "typescript": "^5.8.3" } -} +} \ No newline at end of file diff --git a/client/rsbuild.config.ts b/client/rsbuild.config.ts index 3dd17b3..829a0d8 100644 --- a/client/rsbuild.config.ts +++ b/client/rsbuild.config.ts @@ -12,7 +12,8 @@ const SERVER_PORT = process.env.SERVER_PORT ?? defaultPort; // For project pages: /repo-name/ // For user/organization pages: / const basePath = process.env.GITHUB_PAGES_BASE_PATH ?? '/'; -console.log(`Using base path: "${basePath}"`); +const distRoot = process.env.CLIENT_DIST_PATH ?? '../dist'; +console.log(`Using base path: "${basePath}", dist: "${distRoot}"`); const version = pkgJson.version; @@ -23,7 +24,7 @@ export default defineConfig({ }, output: { distPath: { - root: '../dist', + root: distRoot, }, assetPrefix: basePath, }, diff --git a/client/src/components/Editor/DraftEditor.tsx b/client/src/components/Editor/DraftEditor.tsx index 2f1e3bf..f9a1617 100644 --- a/client/src/components/Editor/DraftEditor.tsx +++ b/client/src/components/Editor/DraftEditor.tsx @@ -5,11 +5,12 @@ import React, { useEffect, useId, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; -import { getGuideDoc } from './internalDocs/guide'; +import { getGuideDoc, getLocalModeGuideDoc } from './internalDocs/guide'; import { getVersionMismatchDoc } from './internalDocs/versionMismatch'; import { CrepeEditor, CrepeEditorRef } from './MilkdownEditor'; import { EditorRef } from './type'; +import { ONLINE_MODE } from '@/constants'; import { updateCurDoc, selectCurTabs, DocType, selectCurDoc, clearCurDoc } from '@/redux-feature/curDocSlice'; import { selectNarrowMode, selectReadonly, selectTheme, updateGlobalOpts } from '@/redux-feature/globalOptsSlice'; @@ -23,7 +24,7 @@ const getDoc = (docId: string, type: DocType) => { return { id: docId, title: `Guide`, - content: getGuideDoc(), + content: ONLINE_MODE ? getGuideDoc() : getLocalModeGuideDoc(), }; } if (docId === 'version-mismatch') { diff --git a/client/src/components/Editor/configs/uploadConfig.ts b/client/src/components/Editor/configs/uploadConfig.ts index 63c557e..21d9696 100644 --- a/client/src/components/Editor/configs/uploadConfig.ts +++ b/client/src/components/Editor/configs/uploadConfig.ts @@ -4,11 +4,11 @@ import { uploadConfig, Uploader } from '@milkdown/kit/plugin/upload'; import type { Ctx } from '@milkdown/kit/ctx'; import type { Node } from '@milkdown/kit/prose/model'; -import { SERVER_PORT } from '@/constants'; +import { SERVER_BASE_URL } from '@/constants'; export function getImageUrl(url: string) { if (url.startsWith('/')) { - return `http://127.0.0.1:${SERVER_PORT}/api/imgs${url}`; + return `${SERVER_BASE_URL}/imgs${url}`; } return url; } @@ -16,7 +16,7 @@ export function getImageUrl(url: string) { export async function uploadImage(file: File) { const formData = new FormData(); formData.append('file', file); - const res = await fetch(`http://127.0.0.1:${SERVER_PORT}/api/imgs/upload`, { + const res = await fetch(`${SERVER_BASE_URL}/imgs/upload`, { method: 'POST', body: formData, }); diff --git a/client/src/components/Editor/internalDocs/guide.ts b/client/src/components/Editor/internalDocs/guide.ts index 666110d..ca6a9e4 100644 --- a/client/src/components/Editor/internalDocs/guide.ts +++ b/client/src/components/Editor/internalDocs/guide.ts @@ -5,9 +5,9 @@ export const getGuideDoc = () => { const serverDownloadUrl = getServerDownloadUrl(APP_VERSION); return ` - ## Guide (${APP_VERSION}) + # Guide (${APP_VERSION}) - Welcome to use the Markdown Editor. + Welcome to use the online version of [Markdown Editor](https://github.com/s-elo/Markdown-editor). ## Install the local server @@ -15,13 +15,24 @@ export const getGuideDoc = () => { 1. Install the [local server](${serverDownloadUrl}). - 2. Unzip the file and run the binary file **as administrator**. + 2. Unzip the file and run the binary file. - :::warning - The file will automatically register auto start when you open your computer. - ::: + > It will also open the local client in your default browser. :::tip + For MacOS, rignt click to open the App. + + For windows, right click to run the executable as **administrator**. + ::: + + ## Setup Workspace + + Now you should be able to connect to local server. + + Open the Menu/Settings to select a folder as workspace. + + ## Manage local server + you can manage the server in terminal \`\`\`bash @@ -32,10 +43,34 @@ export const getGuideDoc = () => { $ mds stop # checkout server status $ mds status + \`\`\` + `; +}; + +export const getLocalModeGuideDoc = () => { + return ` + # Guide (${APP_VERSION}) + + Welcome to use the [Markdown Editor](https://github.com/s-elo/Markdown-editor). + + ## Setup Workspace - # uninstall the server, this will remove the cli - $ mds uninstall + Since you can open this editor, you should be able to connect to local server. + + Open the Menu/Settings to select a folder as workspace. + + ## Manage local server + + you can manage the server in terminal + + \`\`\`bash + # checkout the help information + $ mds -h + + # stop the server + $ mds stop + # checkout server status + $ mds status \`\`\` - ::: `; }; diff --git a/client/src/components/Editor/internalDocs/versionMismatch.ts b/client/src/components/Editor/internalDocs/versionMismatch.ts index d78f8af..ec05d35 100644 --- a/client/src/components/Editor/internalDocs/versionMismatch.ts +++ b/client/src/components/Editor/internalDocs/versionMismatch.ts @@ -10,13 +10,19 @@ export const getVersionMismatchDoc = () => { Current version: ${APP_VERSION} - The version of the local server is different from the current version of the editor. + The version of the local server is different from the current version of this oneline editor. Please install the latest version of the local server. 1. Install the [local server](${serverDownloadUrl}). - 2. Unzip the file and run the binary file **as administrator**. + 2. Unzip the file and run the binary file. + + :::tip + For MacOS, rignt click to open the App. + + For windows, right click to run the executable as **administrator**. + ::: For more information, please refer to the [guide](${guideHref}). `; diff --git a/client/src/components/EditorContainer/EditorContainer.tsx b/client/src/components/EditorContainer/EditorContainer.tsx index fc713a9..69c6c20 100644 --- a/client/src/components/EditorContainer/EditorContainer.tsx +++ b/client/src/components/EditorContainer/EditorContainer.tsx @@ -46,7 +46,13 @@ export const EditorContainer: FC = () => { const globalContent = useSelector(selectCurContent); const defaultPagePath = useMemo(() => { - return curTab ? `/article/${curTab.ident}` : '/purePage'; + if (!curTab) return '/purePage'; + + if (curTab.type === 'workspace') return `/article/${curTab.ident}`; + if (curTab.type === 'draft') return `/draft/${curTab.ident}`; + if (curTab.type === 'internal') return `/internal/${curTab.ident}`; + + return '/purePage'; }, [curTab]); const handleDocMirrorChange = (value: string) => { diff --git a/client/src/components/Footer/Footer.scss b/client/src/components/Footer/Footer.scss index a454e6a..a97dcea 100644 --- a/client/src/components/Footer/Footer.scss +++ b/client/src/components/Footer/Footer.scss @@ -10,6 +10,9 @@ justify-content: space-between; padding: 0 10px; .left-group { + display: flex; + align-items: center; + gap: 7px; .app-info-version-mismatch { svg { path { diff --git a/client/src/components/GitBox/GitBox.scss b/client/src/components/GitBox/GitBox.scss index e63245e..b2f1bd2 100644 --- a/client/src/components/GitBox/GitBox.scss +++ b/client/src/components/GitBox/GitBox.scss @@ -11,7 +11,7 @@ } border-radius: $borderRadius; width: 25rem; - height: 400px; + height: 500px; padding: 0.5rem; background-color: $backgroundColor; display: flex; @@ -53,6 +53,7 @@ } .space-box { width: 100%; + max-height: calc((100% - 2rem) / 2); .clean-space { margin-top: 10px; width: 100%; @@ -92,7 +93,7 @@ display: flex; flex-direction: column; align-items: flex-end; - max-height: 15rem; + max-height: calc(100% - 2rem - 2rem); overflow: auto; .change-item { width: 95%; diff --git a/client/src/components/GitBox/GitBox.tsx b/client/src/components/GitBox/GitBox.tsx index e054818..d241bef 100644 --- a/client/src/components/GitBox/GitBox.tsx +++ b/client/src/components/GitBox/GitBox.tsx @@ -12,7 +12,7 @@ import UndoIcon from '@mui/icons-material/UndoOutlined'; import { InputText } from 'primereact/inputtext'; import { InputTextarea } from 'primereact/inputtextarea'; import { ProgressSpinner } from 'primereact/progressspinner'; -import React, { useCallback, useState } from 'react'; +import React, { FC, useCallback, useState } from 'react'; import { Icon } from '@/components/Icon/Icon'; import { useGetDocSubItemsQuery } from '@/redux-api/docs'; @@ -39,15 +39,46 @@ const defaultStatus = { noGit: true, }; -// eslint-disable-next-line @typescript-eslint/naming-convention -export default function GitBox() { +interface CommitMsgBoxProps { + onCommitMsgTitleChange: (commitMsgTitle: string) => void; + onCommitMsgBodyChange: (commitMsgBody: string) => void; +} +const CommitMsgBox: FC = ({ onCommitMsgTitleChange, onCommitMsgBodyChange }) => { + const [commitMsgTitle, setCommitMsgTitle] = useState(''); + const [commitMsgBody, setCommitMsgBody] = useState(''); + + return ( +
+
Title
+ { + setCommitMsgTitle(e.target.value); + onCommitMsgTitleChange(e.target.value); + }} + className="commit-msg-input" + placeholder="commit message title" + /> +
Body
+ { + setCommitMsgBody(e.target.value); + onCommitMsgBodyChange(e.target.value); + }} + className="commit-msg-input" + placeholder="commit message body" + /> +
+ ); +}; + +export const GitBox: FC = () => { const { navigate, curPath } = useCurPath(); const { data: { noGit, workspace, staged } = defaultStatus, isLoading } = useGetGitStatusQuery(); - const [commitMsgTitle, setCommitMsgTitle] = useState(''); - const [commitMsgBody, setCommitMsgBody] = useState(''); - const [opLoading, setOpLoading] = useState(false); const restoreEffects = useRestoreEffects(); @@ -148,30 +179,16 @@ export default function GitBox() { return; } + let commitMsgTitle = ''; + let commitMsgBody = ''; + if ( !(await confirm({ message: ( -
-
Title
- { - setCommitMsgTitle(e.target.value); - }} - className="commit-msg-input" - placeholder="commit message title" - /> -
Body
- { - setCommitMsgBody(e.target.value); - }} - className="commit-msg-input" - placeholder="commit message body" - /> -
+ (commitMsgBody = body)} + onCommitMsgTitleChange={(title) => (commitMsgTitle = title)} + /> ), })) ) { @@ -401,4 +418,4 @@ export default function GitBox() { } return
{content}
; -} +}; diff --git a/client/src/components/ImgManagement/ImgManagement.scss b/client/src/components/ImgManagement/ImgManagement.scss index b74f622..b6144c8 100644 --- a/client/src/components/ImgManagement/ImgManagement.scss +++ b/client/src/components/ImgManagement/ImgManagement.scss @@ -85,4 +85,37 @@ } } } + + .img-ref-docs { + width: 100%; + padding: 0.5rem 0.75rem 0.25rem; + font-size: 0.85rem; + + .ref-docs-loading, + .ref-docs-empty { + opacity: 0.6; + } + + .ref-docs-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; + + li { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; + } + + .ref-doc-path { + word-break: break-all; + opacity: 0.85; + padding: 5px; + } + } + } } diff --git a/client/src/components/ImgManagement/ImgManagement.tsx b/client/src/components/ImgManagement/ImgManagement.tsx index a26d0d1..97aa2c3 100644 --- a/client/src/components/ImgManagement/ImgManagement.tsx +++ b/client/src/components/ImgManagement/ImgManagement.tsx @@ -2,16 +2,24 @@ import { Button } from 'primereact/button'; import { DataView } from 'primereact/dataview'; import { Dialog } from 'primereact/dialog'; import { Dropdown } from 'primereact/dropdown'; +import { Image } from 'primereact/image'; import { Tag } from 'primereact/tag'; import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; import { getImageUrl } from '@/components/Editor/configs/uploadConfig'; import { Icon } from '@/components/Icon/Icon'; -import { ImgListItem, useDeleteWorkspaceImgMutation, useGetImgListQuery } from '@/redux-api/imgStoreApi'; +import { + ImgListItem, + ImgRefDoc, + useDeleteWorkspaceImgMutation, + useGetImgListQuery, + useLazyGetImgRefDocsQuery, +} from '@/redux-api/img'; import { selectCurContent } from '@/redux-feature/curDocSlice'; import Toast from '@/utils/Toast'; -import { confirm } from '@/utils/utils'; +import { confirm, normalizePath } from '@/utils/utils'; import './ImgManagement.scss'; @@ -55,8 +63,12 @@ export const ImgManagement: FC = () => { skip: !showImgManagementModal, }); const [deleteImg] = useDeleteWorkspaceImgMutation(); + const [fetchRefDocs] = useLazyGetImgRefDocsQuery(); + const [refDocsFor, setRefDocsFor] = useState(null); + const [refDocs, setRefDocs] = useState([]); + const [refDocsLoading, setRefDocsLoading] = useState(false); const curContent = useSelector(selectCurContent); - + const navigate = useNavigate(); const usedUrls = useMemo(() => extractImageUrls(curContent || ''), [curContent]); const handleDelete = useCallback( @@ -70,6 +82,26 @@ export const ImgManagement: FC = () => { [deleteImg], ); + const handleFindRefs = useCallback( + async (fileName: string) => { + if (refDocsFor === fileName) { + setRefDocsFor(null); + return; + } + setRefDocsFor(fileName); + setRefDocsLoading(true); + try { + const docs = await fetchRefDocs(fileName).unwrap(); + setRefDocs(docs); + } catch { + setRefDocs([]); + } finally { + setRefDocsLoading(false); + } + }, + [fetchRefDocs, refDocsFor], + ); + const filteredImages = useMemo(() => { if (filterMode === 'all') return images; return images.filter((img) => { @@ -88,7 +120,7 @@ export const ImgManagement: FC = () => { return (
- {img.fileName} + {img.fileName}
{img.fileName}
@@ -97,6 +129,17 @@ export const ImgManagement: FC = () => {
+
+ {refDocsFor === img.fileName && ( +
+ {refDocsLoading ? ( + Loading... + ) : refDocs.length === 0 ? ( + No documents reference this image. + ) : ( +
    + {refDocs.map((doc) => { + const docPath = doc.path.join('/'); + return ( +
  • + + +
  • + ); + })} +
+ )} +
+ )}
); }; diff --git a/client/src/components/Menu/Menu.tsx b/client/src/components/Menu/Menu.tsx index 07f830a..b48f10b 100644 --- a/client/src/components/Menu/Menu.tsx +++ b/client/src/components/Menu/Menu.tsx @@ -245,7 +245,7 @@ export const Menu: FC = () => { } else if (isFetching) { content = ; } else if (isError) { - if (serverStatus === ServerStatus.RUNNING) { + if (serverStatus === ServerStatus.RUNNING && settings?.docRootPath) { content = (
diff --git a/client/src/components/Menu/operations.tsx b/client/src/components/Menu/operations.tsx index e7618ac..05487c9 100644 --- a/client/src/components/Menu/operations.tsx +++ b/client/src/components/Menu/operations.tsx @@ -278,6 +278,7 @@ export const usePasteDoc = () => { providedTreeDataCtx, providedIsCopy, providedCopyCutPaths, + onCancel, }: { /** the path of the clicked item */ pasteParentPathArr: string[]; @@ -288,6 +289,7 @@ export const usePasteDoc = () => { providedIsCopy?: boolean; /** normalized */ providedCopyCutPaths?: string[]; + onCancel?: () => Promise | void; }) => { const treeCtx = providedTreeDataCtx ?? treeDataCtx; if (!treeCtx) return; @@ -340,16 +342,20 @@ export const usePasteDoc = () => { if ( !isCopy && !(await confirm({ - message: `Are you sure to move ${ - copyCutPayload - .reduce((ret, { copyCutPath }) => { - ret.push(denormalizePath(copyCutPath).join('/')); - return ret; - }, []) - .join(', ') as string - } to ${pasteParentPathArr.join('/') || 'root'}?`, + message: ( +
+ Are you sure to move +
    + {copyCutPayload.map(({ copyCutPath }) => ( +
  • {denormalizePath(copyCutPath).join('/')}
  • + ))} +
+ to {pasteParentPathArr.join('/') || 'root'}? +
+ ), })) ) { + await onCancel?.(); return; } @@ -420,33 +426,6 @@ export const useDropDoc = () => { const targetItemIdx = target.targetType === 'between-items' ? target.parentItem : target.targetItem; const targetItem = treeData[targetItemIdx]; - const isConfirm = await confirm({ - message: 'Are you sure to move the items?', - }); - if (!isConfirm) { - // reorder the moved items - await Promise.all( - items.map(async (item) => { - // original parent - const parentIdx = item.data.parentIdx; - const parentItem = treeData[parentIdx]; - if (parentItem?.children) { - // make sure the order - const { data: subDocItems, status } = await getDocSubItems({ - folderDocPath: parentItem.data.path.join('/'), - }); - if (status !== QueryStatus.fulfilled) { - Toast.error('Failed to get sub doc items'); - return; - } - parentItem.children = subDocItems.map((d) => normalizePath(d.path)); - } - targetItem?.children?.splice(targetItem?.children?.indexOf(item.index), 1); - }), - ); - return; - } - await pasteDoc({ pasteParentPathArr: targetItem.data.path, providedTreeDataCtx: { @@ -455,6 +434,28 @@ export const useDropDoc = () => { }, providedIsCopy: false, providedCopyCutPaths: items.map((item) => normalizePath(item.data.path)), + onCancel: async () => { + // reorder the moved items + await Promise.all( + items.map(async (item) => { + // original parent + const parentIdx = item.data.parentIdx; + const parentItem = treeData[parentIdx]; + if (parentItem?.children) { + // make sure the order + const { data: subDocItems, status } = await getDocSubItems({ + folderDocPath: parentItem.data.path.join('/'), + }); + if (status !== QueryStatus.fulfilled) { + Toast.error('Failed to get sub doc items'); + return; + } + parentItem.children = subDocItems.map((d) => normalizePath(d.path)); + } + targetItem?.children?.splice(targetItem?.children?.indexOf(item.index), 1); + }), + ); + }, }); }; }; diff --git a/client/src/components/Sidebar/Sidebar.tsx b/client/src/components/Sidebar/Sidebar.tsx index a636b77..f1af0fa 100644 --- a/client/src/components/Sidebar/Sidebar.tsx +++ b/client/src/components/Sidebar/Sidebar.tsx @@ -9,7 +9,7 @@ import { FC, useEffect, useMemo, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; -import GitBox from '@/components/GitBox/GitBox'; +import { GitBox } from '@/components/GitBox/GitBox'; import { Icon } from '@/components/Icon/Icon'; import { SettingsBox } from '@/components/Settings/Settings'; import { Settings, useGetSettingsQuery, useUpdateSettingsMutation } from '@/redux-api/settings'; @@ -18,7 +18,7 @@ import { clearAllDrafts } from '@/redux-feature/draftsSlice'; import { updateGlobalOpts, selectGlobalOpts, selectServerStatus, ServerStatus } from '@/redux-feature/globalOptsSlice'; import ErrorBoundary from '@/utils/ErrorBoundary/ErrorBoundary'; import Toast from '@/utils/Toast'; -import { isEqual } from '@/utils/utils'; +import { isEqual, nextTick } from '@/utils/utils'; import './Sidebar.scss'; @@ -57,16 +57,20 @@ export const Sidebar: FC = () => { const isRootPathChanged = newSettings.docRootPath !== settings?.docRootPath; - await updateSettings(newSettings).unwrap(); - - Toast('Settings updated successfully'); - if (isRootPathChanged) { await navigate('/purePage'); dispatch(updateTabs([])); // TODO: should save the tabs and drafts for each workspace dispatch(clearAllDrafts()); + await nextTick(() => { + // wait for the editor to unmount + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + }, 500); } + + await updateSettings(newSettings).unwrap(); + + Toast('Settings updated successfully'); } catch (e) { Toast.error((e as Error).message); } finally { diff --git a/client/src/components/UploadImg/UploadImg.scss b/client/src/components/UploadImg/UploadImg.scss deleted file mode 100644 index 33d11ee..0000000 --- a/client/src/components/UploadImg/UploadImg.scss +++ /dev/null @@ -1,51 +0,0 @@ -.upload-block { - width: 30rem; - height: 15rem; - margin: 1rem 2rem; - padding: 2rem; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - border: 0.2rem dashed #e6e6e6; - cursor: pointer; - .upload-icon { - font-size: 8rem; - color: #bcbaba; - } - .upload-prompt { - color: #585454; - } -} -.upload-input { - display: none; -} -.img-name-input { - width: 30rem; - margin: 0.5rem 2rem 1rem 2rem; - display: block; - padding: 0.5rem; - outline: none; - border-radius: 5px; - border: 0.5px solid #e7e0e0; - position: relative; - &:focus { - border-color: #62b5ec; - } -} -.upload-img-show { - width: 30rem; - max-height: 70vh; - margin: 1rem 2rem; - overflow-y: auto; - object-fit: cover; -} -.reselect-prompt { - margin: 0 2rem; - color: #8c8a8a; - cursor: pointer; - font-weight: bold; - &:hover { - color: black; - } -} diff --git a/client/src/components/UploadImg/UploadImg.tsx b/client/src/components/UploadImg/UploadImg.tsx deleted file mode 100644 index 0083e59..0000000 --- a/client/src/components/UploadImg/UploadImg.tsx +++ /dev/null @@ -1,176 +0,0 @@ -/* eslint-disable @typescript-eslint/no-misused-promises */ -import React, { useCallback, useEffect, useRef, useState } from 'react'; - -import Modal from '../../utils/Modal/Modal'; - -import { useUploadImgMutation } from '@/redux-api/imgStoreApi'; -import Spinner from '@/utils/Spinner/Spinner'; -import Toast from '@/utils/Toast'; -import { getImgUrl } from '@/utils/utils'; - -import './UploadImg.scss'; - -// eslint-disable-next-line @typescript-eslint/naming-convention -export default function UploadImg() { - const [modalShow, setModalShow] = useState(false); - const [imgUrl, setImgUrl] = useState(''); - const [imgName, setImgName] = useState(''); - const [isFetching, setIsFetching] = useState(false); - - const uploadFile = useRef(null); - - const uploadInputRef = useRef(null); - - const [uploadImgMutation] = useUploadImgMutation(); - - const uploadClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - if (!uploadInputRef.current || isFetching) return; - - uploadInputRef.current.click(); - }, - [uploadInputRef, isFetching], - ); - - const selectImg = useCallback( - (e: React.ChangeEvent) => { - const files = e.target.files; - if (!files) return; - - const imgFile = files[0]; - - const url = getImgUrl(imgFile); - - setImgUrl(url); - uploadFile.current = imgFile; - setImgName(imgFile.name.split('.')[0]); - }, - [setImgUrl, uploadFile, setImgName], - ); - - const pasteImg = useCallback( - async (e: ClipboardEvent) => { - if (!e.clipboardData?.items || e.clipboardData.items.length === 0) return; - - let imgFile: File | null = null; - - const item = e.clipboardData.items[0]; - - if (item.kind === 'file') { - imgFile = item.getAsFile(); - } else if (item.kind === 'string') { - setIsFetching(true); - await new Promise((res) => { - // eslint-disable-next-line @typescript-eslint/no-misused-promises - item.getAsString(async (str) => { - const resp = await fetch(str); - const data = await resp.blob(); - imgFile = new File([data], data.type.replace('/', '.'), { - type: data.type, - }); - res(); - }); - }).catch((reason) => { - Toast.error(String(reason)); - imgFile = null; - }); - setIsFetching(false); - } - - if (!imgFile) return; - - const url = getImgUrl(imgFile); - - // this must be called before setImgUrl - // since the setImgUrl will be run asyncly when this is native event binding - uploadFile.current = imgFile; - setImgUrl(url); - setImgName(imgFile.name.split('.')[0]); - }, - [setImgUrl, uploadFile, setImgName, setIsFetching], - ); - - const uploadImg = useCallback(async () => { - if (uploadFile.current == null) { - Toast.warn('please select or paste an image'); - return; - } - - try { - await uploadImgMutation({ - imgFile: uploadFile.current, - fileName: `${imgName}.${uploadFile.current.name.split('.')[1]}`, - }).unwrap(); - } catch (err) { - Toast.error((err as Error).message); - } - }, [uploadFile, uploadImgMutation, imgName]); - - // binding paste event on document - useEffect(() => { - if (modalShow) document.addEventListener('paste', pasteImg); - // remove the event when the modal is closing - else document.removeEventListener('paste', pasteImg); - - return () => { - document.removeEventListener('paste', pasteImg); - }; - }, [pasteImg, modalShow]); - - return ( - <> - { - setModalShow(true); - }} - title="upload-img" - role="button" - > - image - - {modalShow && ( - { - setLoading(true); - await uploadImg(); - setLoading(false); - }} - > -
- {!isFetching ? ( - <> -
+
-
click to upload an image or just ctrl+v
- - ) : ( - - )} -
- - - { - setImgName(e.target.value); - }} - style={{ display: uploadFile.current == null ? 'none' : 'block' }} - /> - -
- )} - - ); -} diff --git a/client/src/constants.ts b/client/src/constants.ts index 71d3f3c..491f827 100644 --- a/client/src/constants.ts +++ b/client/src/constants.ts @@ -3,3 +3,7 @@ export const APP_VERSION = __VERSION__; export const GITHUB_PAGES_BASE_PATH = __GITHUB_PAGES_BASE_PATH__; /** 3024 or 7024*/ export const SERVER_PORT = __SERVER_PORT__; + +export const ONLINE_MODE = Boolean(SERVER_PORT); + +export const SERVER_BASE_URL = ONLINE_MODE ? `http://127.0.0.1:${SERVER_PORT}/api` : '/api'; diff --git a/client/src/redux-api/docs.ts b/client/src/redux-api/docs.ts index 346192f..f5c9f80 100644 --- a/client/src/redux-api/docs.ts +++ b/client/src/redux-api/docs.ts @@ -13,7 +13,11 @@ import { } from './docsApiType'; import { transformResponse, transformErrorResponse } from './interceptor'; -const baseQuery = fetchBaseQuery({ baseUrl: `http://127.0.0.1:${__SERVER_PORT__}/api` }); +import { SERVER_BASE_URL } from '@/constants'; + +const baseQuery = fetchBaseQuery({ + baseUrl: SERVER_BASE_URL, +}); export const docsApi = createApi({ reducerPath: '/docs', diff --git a/client/src/redux-api/imgStoreApi.ts b/client/src/redux-api/img.ts similarity index 57% rename from client/src/redux-api/imgStoreApi.ts rename to client/src/redux-api/img.ts index 830a1bd..81e71d3 100644 --- a/client/src/redux-api/imgStoreApi.ts +++ b/client/src/redux-api/img.ts @@ -39,6 +39,11 @@ export interface RenameType { newName: string; } +export interface ImgRefDoc { + path: string[]; + count: number; +} + const imgApi = docsApi.injectEndpoints({ endpoints: (builder) => ({ getImgList: builder.query({ @@ -57,42 +62,10 @@ const imgApi = docsApi.injectEndpoints({ transformResponse, transformErrorResponse, }), - getUploadHistory: builder.query<{ imgList: ImgDataType[]; err: 0 | 1; message: string }, void>({ - query: () => `/imgStore/uploadHistory`, - providesTags: ['ImgStore'], - keepUnusedDataFor: 300, - }), - uploadImg: builder.mutation({ - query: ({ imgFile, fileName }) => { - const formData = new FormData(); - formData.append('imgFile', imgFile); - formData.append('fileName', fileName); - - return { - url: '/imgStore/upload', - method: 'POST', - body: formData, - }; - }, - invalidatesTags: ['ImgStore'], - }), - deleteImg: builder.mutation({ - query: (imgName) => ({ - url: '/imgStore/delete', - method: 'DELETE', - body: { - imgName, - }, - }), - invalidatesTags: ['ImgStore'], - }), - renameImg: builder.mutation({ - query: (renameInfo) => ({ - url: '/imgStore/rename', - method: 'PATCH', - body: renameInfo, - }), - invalidatesTags: ['ImgStore'], + getImgRefDocs: builder.query({ + query: (fileName) => `/imgs/ref-docs?fileName=${encodeURIComponent(fileName)}`, + transformResponse, + transformErrorResponse, }), }), @@ -107,11 +80,4 @@ const imgApi = docsApi.injectEndpoints({ overrideExisting: false, }); -export const { - useGetImgListQuery, - useDeleteWorkspaceImgMutation, - useGetUploadHistoryQuery, - useUploadImgMutation, - useDeleteImgMutation, - useRenameImgMutation, -} = imgApi; +export const { useGetImgListQuery, useDeleteWorkspaceImgMutation, useLazyGetImgRefDocsQuery } = imgApi; diff --git a/client/src/redux-api/settings.ts b/client/src/redux-api/settings.ts index e378edc..74a5350 100644 --- a/client/src/redux-api/settings.ts +++ b/client/src/redux-api/settings.ts @@ -25,7 +25,7 @@ const settingsApi = docsApi.injectEndpoints({ }, transformResponse, transformErrorResponse, - invalidatesTags: ['Configs', 'Menu', 'GitStatus', 'ImgStore', 'Article'], + invalidatesTags: ['Configs', 'Menu', 'GitStatus', 'ImgList', 'Article'], }), }), diff --git a/client/src/utils/utils.ts b/client/src/utils/utils.ts index 8816ed2..0631808 100644 --- a/client/src/utils/utils.ts +++ b/client/src/utils/utils.ts @@ -268,10 +268,14 @@ export function updateLocationHash(hash: string) { history.replaceState(null, '', `${location}#${hash}`); } -export const nextTick = (fn: () => Promise | void, time = 0) => { - setTimeout(() => { - void fn(); - }, time); +export const nextTick = async (fn: () => Promise | void, time = 0) => { + return new Promise((res) => { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + setTimeout(async () => { + await fn(); + res(); + }, time); + }); }; export async function waitAndCheck(isHit: () => boolean, wait = 50, maxTry = 10) { @@ -319,7 +323,7 @@ export function uid(len = 5) { export function getServerDownloadUrl(appVersion: string) { const isMacos = window.navigator.userAgent.includes('Mac'); - return `https://github.com/s-elo/Markdown-editor/releases/download/v${appVersion}/mds-${ + return `https://github.com/s-elo/Markdown-editor/releases/download/v${appVersion}/markdown-editor-${ isMacos ? 'macos' : 'windows' }.zip`; } diff --git a/crates/BUILD.md b/crates/BUILD.md index 599dc39..3951dd7 100644 --- a/crates/BUILD.md +++ b/crates/BUILD.md @@ -1,6 +1,6 @@ # Build Guide -This document explains how to build MDS-Server for different platforms. +This document explains how to build Markdown-Editor for different platforms. ## Cross-Platform Build Script @@ -28,6 +28,7 @@ node build.js windows --msvc # MSVC toolchain (Windows only) ``` Or via pnpm: + ```bash pnpm build:macos pnpm build:windows @@ -48,27 +49,29 @@ These work on macOS and Linux, but not on Windows (use `build.js` instead). ### Building Windows from macOS/Linux **Requirements:** + 1. Install Rust target: `rustup target add x86_64-pc-windows-gnu` 2. Install MinGW-w64: - **macOS**: `brew install mingw-w64` - **Linux**: `sudo apt-get install mingw-w64` (Debian/Ubuntu) or `sudo yum install mingw64-gcc` (RHEL/CentOS) **Usage:** + ```bash node build.js windows -# or -./build-windows.sh ``` ### Building macOS from Windows/Linux -**Note:** Building macOS binaries from non-macOS systems is **not recommended** and requires complex setup (osxcross). +**Note:** Building macOS binaries from non-macOS systems is **not recommended** and requires complex setup (osxcross). **Recommended approach:** + - Use GitHub Actions with macOS runners (see `.github/workflows/build.yml`) - Or build natively on macOS **If you must cross-compile:** + 1. Install Rust targets: `rustup target add x86_64-apple-darwin aarch64-apple-darwin` 2. Set up osxcross (complex, see osxcross documentation) 3. Configure Cargo to use osxcross toolchain @@ -76,6 +79,7 @@ node build.js windows ### Building macOS from macOS **Requirements:** + 1. Install Rust targets: ```bash rustup target add x86_64-apple-darwin @@ -83,10 +87,9 @@ node build.js windows ``` **Usage:** + ```bash node build.js macos -# or -./build-macos.sh ``` ## GitHub CI @@ -94,7 +97,7 @@ node build.js macos The project includes a GitHub Actions workflow (`.github/workflows/build.yml`) that: - Builds macOS binaries on macOS runners -- Builds Windows binaries on Windows runners +- Builds Windows binaries on Windows runners - Builds Linux binaries and cross-compiles Windows on Linux runners This is the **recommended way** to build for all platforms reliably. @@ -103,13 +106,14 @@ This is the **recommended way** to build for all platforms reliably. All builds create artifacts in the `dist/` directory: -- **macOS**: `dist/MDS-Server.app` and `dist/mds-macos.zip` -- **Windows**: `dist/mds.exe` and `dist/mds-windows.zip` +- **macOS**: `dist/Markdown-Editor.app` and `dist/markdown-editor-macos.zip` +- **Windows**: `dist/Markdown-Editor.exe` and `dist/markdown-editor-windows.zip` - **Linux**: `target/release/mds` (binary only) ## Troubleshooting ### MinGW not found on macOS + ```bash brew install mingw-w64 export PATH="/opt/homebrew/bin:$PATH" # Apple Silicon @@ -118,12 +122,13 @@ export PATH="/usr/local/bin:$PATH" # Intel Mac ``` ### Rust target not installed + ```bash rustup target add ``` ### Permission denied on scripts + ```bash -chmod +x build-macos.sh build-windows.sh build.js +chmod +x build.js ``` - diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 2ae6cef..f932b94 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -395,6 +395,12 @@ dependencies = [ "encoding_rs", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -447,6 +453,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.31" @@ -563,6 +575,12 @@ dependencies = [ "memmap2", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "heck" version = "0.5.0" @@ -603,6 +621,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -770,6 +794,35 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -926,6 +979,9 @@ dependencies = [ "daemonize", "dirs", "nix", + "open", + "serde", + "serde_json", "server", "sysinfo", "tokio", @@ -934,6 +990,7 @@ dependencies = [ "tracing-subscriber", "windows-service", "winreg", + "winresource", ] [[package]] @@ -957,6 +1014,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.1.0" @@ -1033,6 +1100,17 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openssl-probe" version = "0.1.6" @@ -1102,6 +1180,12 @@ dependencies = [ "syn", ] +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1328,6 +1412,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1611,6 +1704,58 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + [[package]] name = "tower" version = "0.5.2" @@ -1635,9 +1780,19 @@ checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ "bitflags 2.10.0", "bytes", + "futures-core", + "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -1736,6 +1891,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.19" @@ -2265,6 +2426,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" + [[package]] name = "winreg" version = "0.52.0" @@ -2275,6 +2442,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "winresource" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e287ced0f21cd11f4035fe946fd3af145f068d1acb708afd248100f89ec7432d" +dependencies = [ + "toml", + "version_check", +] + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 983a41b..20a6176 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -20,7 +20,7 @@ serde_json = "1.0.145" struct-patch = "0.10.4" tokio = { version = "1.0", features = ["full"] } tower = "0.5.2" -tower-http = { version = "0.6", features = ["trace", "request-id", "normalize-path"] } +tower-http = { version = "0.6", features = ["trace", "request-id", "normalize-path", "fs"] } tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/build-macos.sh b/crates/build-macos.sh deleted file mode 100755 index 9d69ae3..0000000 --- a/crates/build-macos.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/bin/bash -set -e - -# Build script for creating macOS .app bundle -# Usage: ./crates/build-macos.sh [--intel|--arm|--universal] -# --intel Build for Intel Macs only (x86_64) -# --arm Build for Apple Silicon only (aarch64) -# --universal Build universal binary (default, works on both) - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -CRATES_DIR="${PROJECT_ROOT}/crates" -DIST_DIR="${PROJECT_ROOT}/dist" -APP_NAME="MDS-Server" -BINARY_NAME="mds" -BUNDLE_ID="com.markdown-editor.mds" - -# Parse arguments -BUILD_TYPE="${1:-universal}" - -# Read version from project root package.json -VERSION=$(node -p "require('${PROJECT_ROOT}/package.json').version" 2>/dev/null || echo "1.0.0") - -echo "Building MDS-Server v${VERSION}..." - -cd "$CRATES_DIR" - -# Detect host architecture -HOST_ARCH=$(uname -m) -if [ "$HOST_ARCH" = "arm64" ]; then - HOST_TARGET="aarch64-apple-darwin" -else - HOST_TARGET="x86_64-apple-darwin" -fi - -# Function to build for a specific target -build_for_target() { - local target=$1 - local target_name=$2 - - echo "Building release binary for ${target_name}..." - - # Set up OpenSSL environment for cross-compilation if needed - if [ "$target" != "$HOST_TARGET" ]; then - # Try to find OpenSSL via Homebrew for cross-compilation - if command -v brew >/dev/null 2>&1; then - OPENSSL_DIR=$(brew --prefix openssl@3 2>/dev/null || brew --prefix openssl@1.1 2>/dev/null || echo "") - if [ -n "$OPENSSL_DIR" ] && [ -d "$OPENSSL_DIR" ]; then - export OPENSSL_DIR - export OPENSSL_LIB_DIR="${OPENSSL_DIR}/lib" - export OPENSSL_INCLUDE_DIR="${OPENSSL_DIR}/include" - echo " Using OpenSSL from: $OPENSSL_DIR" - fi - fi - fi - - cargo build --release -p md-server --target "$target" -} - -# Build based on selected type -case "$BUILD_TYPE" in - --intel) - build_for_target "x86_64-apple-darwin" "Intel (x86_64)" - BINARY_PATH="target/x86_64-apple-darwin/release/${BINARY_NAME}" - ;; - --arm) - build_for_target "aarch64-apple-darwin" "Apple Silicon (aarch64)" - BINARY_PATH="target/aarch64-apple-darwin/release/${BINARY_NAME}" - ;; - --universal|*) - echo "Building universal binary (Intel + Apple Silicon)..." - echo " → Building for x86_64-apple-darwin..." - build_for_target "x86_64-apple-darwin" "Intel (x86_64)" - echo " → Building for aarch64-apple-darwin..." - build_for_target "aarch64-apple-darwin" "Apple Silicon (aarch64)" - echo " → Creating universal binary with lipo..." - mkdir -p target/universal-apple-darwin/release - lipo -create -output "target/universal-apple-darwin/release/${BINARY_NAME}" \ - "target/x86_64-apple-darwin/release/${BINARY_NAME}" \ - "target/aarch64-apple-darwin/release/${BINARY_NAME}" - BINARY_PATH="target/universal-apple-darwin/release/${BINARY_NAME}" - ;; -esac - -echo "Creating app bundle..." - -# Create dist directory -mkdir -p "$DIST_DIR" - -# Clean up previous build -rm -rf "${DIST_DIR}/${APP_NAME}.app" -rm -f "${DIST_DIR}/mds-macos.zip" - -# Create app bundle structure -mkdir -p "${DIST_DIR}/${APP_NAME}.app/Contents/MacOS" -mkdir -p "${DIST_DIR}/${APP_NAME}.app/Contents/Resources" - -# Copy binary -cp "${BINARY_PATH}" "${DIST_DIR}/${APP_NAME}.app/Contents/MacOS/" - -# Create Info.plist -cat > "${DIST_DIR}/${APP_NAME}.app/Contents/Info.plist" << EOF - - - - - CFBundleExecutable - ${BINARY_NAME} - CFBundleIdentifier - ${BUNDLE_ID} - CFBundleName - ${APP_NAME} - CFBundleDisplayName - Markdown Editor Server - CFBundleVersion - ${VERSION} - CFBundleShortVersionString - ${VERSION} - CFBundlePackageType - APPL - LSUIElement - - LSMinimumSystemVersion - 10.13 - NSHighResolutionCapable - - - -EOF - -# Create PkgInfo -echo -n "APPL????" > "${DIST_DIR}/${APP_NAME}.app/Contents/PkgInfo" - -echo "Creating zip archive..." -cd "$DIST_DIR" -zip -r "mds-macos.zip" "${APP_NAME}.app" - -echo "" -echo "āœ“ Build complete!" -echo " Version: ${VERSION}" -echo " Build type: ${BUILD_TYPE#--}" -echo " App bundle: ${DIST_DIR}/${APP_NAME}.app" -echo " Zip file: ${DIST_DIR}/mds-macos.zip" -echo "" -echo "To test locally:" -echo " open '${DIST_DIR}/${APP_NAME}.app'" -echo "" -echo "To distribute:" -echo " Upload dist/mds-macos.zip to GitHub Pages or your download server" -echo "" -echo "Build options:" -echo " ./crates/build-macos.sh --intel # Intel Macs only" -echo " ./crates/build-macos.sh --arm # Apple Silicon only" -echo " ./crates/build-macos.sh --universal # Both (default)" - diff --git a/crates/build-windows.sh b/crates/build-windows.sh deleted file mode 100755 index 8e9be44..0000000 --- a/crates/build-windows.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/bin/bash -set -e - -# Build script for creating Windows executable -# Usage: ./crates/build-windows.sh [--gnu|--msvc] -# --gnu Build using GNU toolchain (cross-compile from macOS/Linux, default) -# --msvc Build using MSVC toolchain (requires Windows or special setup) - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -CRATES_DIR="${PROJECT_ROOT}/crates" -DIST_DIR="${PROJECT_ROOT}/dist" -BINARY_NAME="mds" -EXE_NAME="${BINARY_NAME}.exe" - -# Parse arguments -BUILD_TYPE="${1:-gnu}" - -# Read version from project root package.json -VERSION=$(node -p "require('${PROJECT_ROOT}/package.json').version" 2>/dev/null || echo "1.0.0") - -echo "Building MDS-Server v${VERSION} for Windows..." - -cd "$CRATES_DIR" - -# Check dependencies before building -echo "Checking dependencies..." - -# Check if building with GNU toolchain -if [ "$BUILD_TYPE" = "--gnu" ] || [ "$BUILD_TYPE" = "gnu" ]; then - # Check if Windows GNU target is installed - if ! rustup target list --installed | grep -q "x86_64-pc-windows-gnu"; then - echo "ERROR: Windows GNU target not installed." - echo "Run: rustup target add x86_64-pc-windows-gnu" - exit 1 - fi - - # Check if MinGW-w64 compiler is available - if ! which x86_64-w64-mingw32-gcc >/dev/null 2>&1; then - echo "ERROR: MinGW-w64 not found." - echo "Install with: brew install mingw-w64" - echo "After installation, ensure it's in your PATH" - exit 1 - fi - - echo "āœ“ Dependencies found" -fi - -# Build based on selected type -case "$BUILD_TYPE" in - --gnu|gnu) - echo "Building release binary for Windows (GNU toolchain)..." - cargo build --release --workspace --target x86_64-pc-windows-gnu - BINARY_PATH="target/x86_64-pc-windows-gnu/release/${EXE_NAME}" - ;; - --msvc) - echo "Building release binary for Windows (MSVC toolchain)..." - cargo build --release --workspace --target x86_64-pc-windows-msvc - BINARY_PATH="target/x86_64-pc-windows-msvc/release/${EXE_NAME}" - ;; - *) - echo "Unknown build type: $BUILD_TYPE" - echo "Use --gnu (default) or --msvc" - exit 1 - ;; -esac - -# Check if binary was created -if [ ! -f "$BINARY_PATH" ]; then - echo "ERROR: Binary not found at $BINARY_PATH" - exit 1 -fi - -echo "Creating dist directory..." -mkdir -p "$DIST_DIR" - -# Clean up previous build -rm -f "${DIST_DIR}/${EXE_NAME}" -rm -f "${DIST_DIR}/mds-windows.zip" - -# Copy binary to dist -echo "Copying binary to dist..." -cp "$BINARY_PATH" "${DIST_DIR}/${EXE_NAME}" - -# Create zip archive -echo "Creating zip archive..." -cd "$DIST_DIR" -zip -q "mds-windows.zip" "${EXE_NAME}" - -echo "" -echo "āœ“ Build complete!" -echo " Version: ${VERSION}" -echo " Build type: ${BUILD_TYPE#--}" -echo " Binary: ${DIST_DIR}/${EXE_NAME}" -echo " Zip file: ${DIST_DIR}/mds-windows.zip" -echo "" -echo "Build options:" -echo " ./crates/build-windows.sh --gnu # GNU toolchain (default, cross-compile)" -echo " ./crates/build-windows.sh --msvc # MSVC toolchain (requires Windows)" - diff --git a/crates/build.js b/crates/build.js index 9e31d0c..ebe1202 100755 --- a/crates/build.js +++ b/crates/build.js @@ -2,11 +2,12 @@ /* eslint-disable @typescript-eslint/no-var-requires */ /** - * Cross-platform build script for MDS-Server + * Cross-platform build script for Markdown-Editor * Works on Windows, macOS, and Linux * Usage: node build.js [platform] [options] * platform: macos, windows, or all (default: current platform) * options: --intel, --arm, --universal (for macOS), --gnu, --msvc (for Windows) + * --skip-client Skip building the client (use pre-built client assets) */ const { execSync } = require('child_process'); @@ -18,12 +19,14 @@ const SCRIPT_DIR = __dirname; const PROJECT_ROOT = path.dirname(SCRIPT_DIR); const CRATES_DIR = SCRIPT_DIR; const DIST_DIR = path.join(PROJECT_ROOT, 'dist'); +const CLIENT_DIST_DIR = path.join(PROJECT_ROOT, 'dist-local'); const BINARY_NAME = 'mds'; // Parse arguments const args = process.argv.slice(2); const platform = args.find((arg) => ['macos', 'windows', 'all'].includes(arg)) || getCurrentPlatform(); const options = args.filter((arg) => arg.startsWith('--')); +const skipClient = options.includes('--skip-client'); // Get version from package.json function getVersion() { @@ -82,17 +85,111 @@ function checkRustTarget(target, installCmd) { } } +/** + * Build the client for local bundling (same-origin serving). + * Uses SERVER_PORT= (empty) so the client uses relative API URLs. + * Outputs to dist-local/ to avoid conflicting with GitHub Pages dist/. + */ +function buildClient() { + if (skipClient) { + if (!fs.existsSync(CLIENT_DIST_DIR)) { + console.error('ERROR: --skip-client specified but no pre-built client found at', CLIENT_DIST_DIR); + process.exit(1); + } + console.log('Skipping client build, using pre-built assets from', CLIENT_DIST_DIR); + return; + } + + console.log('\nBuilding client for local bundle...'); + + // Clean previous local build + if (fs.existsSync(CLIENT_DIST_DIR)) { + fs.rmSync(CLIENT_DIST_DIR, { recursive: true, force: true }); + } + + const env = { + ...process.env, + SERVER_PORT: '', + CLIENT_DIST_PATH: CLIENT_DIST_DIR, + }; + + try { + console.log('> pnpm --filter client build'); + execSync('pnpm --filter client build', { + stdio: 'inherit', + cwd: PROJECT_ROOT, + env, + }); + } catch (error) { + console.error('ERROR: Client build failed:', error.message); + process.exit(1); + } + + if (!fs.existsSync(path.join(CLIENT_DIST_DIR, 'index.html'))) { + console.error('ERROR: Client build did not produce index.html in', CLIENT_DIST_DIR); + process.exit(1); + } + + console.log('āœ“ Client built to', CLIENT_DIST_DIR); +} + +/** + * Copy client assets from dist-local/ to a destination directory. + */ +function copyClientAssets(destDir) { + console.log(`Copying client assets to ${destDir}...`); + + if (!fs.existsSync(CLIENT_DIST_DIR)) { + console.warn('Warning: No client build found at', CLIENT_DIST_DIR, '- skipping client bundling'); + return; + } + + if (fs.existsSync(destDir)) { + fs.rmSync(destDir, { recursive: true, force: true }); + } + fs.mkdirSync(destDir, { recursive: true }); + copyDirSync(CLIENT_DIST_DIR, destDir); +} + +function copyDirSync(src, dest) { + const entries = fs.readdirSync(src, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) { + fs.mkdirSync(destPath, { recursive: true }); + copyDirSync(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + +/** + * Check if Windows icon exists, warn if not found. + */ +function checkWindowsIcon() { + const icoPath = path.join(CRATES_DIR, 'cli', 'assets', 'icon.ico'); + if (!fs.existsSync(icoPath)) { + console.warn('Warning: icon.ico not found at', icoPath); + console.warn('Run: node generate-icons.js --windows to generate it.'); + } else { + console.log('Using pre-generated icon.ico'); + } +} + function buildWindows(useGnu = true) { const version = getVersion(); - console.log(`\nBuilding MDS-Server v${version} for Windows...\n`); + console.log(`\nBuilding Markdown-Editor v${version} for Windows...\n`); + + buildClient(); + checkWindowsIcon(); if (useGnu) { - // Check dependencies if (!checkRustTarget('x86_64-pc-windows-gnu', 'rustup target add x86_64-pc-windows-gnu')) { process.exit(1); } - // Check for MinGW (on Unix systems) if (os.platform() !== 'win32') { if ( !checkCommand( @@ -108,9 +205,8 @@ function buildWindows(useGnu = true) { exec('cargo build --release --workspace --target x86_64-pc-windows-gnu'); const exePath = path.join(CRATES_DIR, 'target', 'x86_64-pc-windows-gnu', 'release', 'mds.exe'); - copyToDist(exePath, 'mds.exe', 'mds-windows.zip'); + packageWindows(exePath, version); } else { - // MSVC (only works on Windows natively) if (os.platform() !== 'win32') { console.error('ERROR: MSVC toolchain requires Windows OS.'); console.error('Use --gnu for cross-compilation from macOS/Linux.'); @@ -125,15 +221,65 @@ function buildWindows(useGnu = true) { exec('cargo build --release --workspace --target x86_64-pc-windows-msvc'); const exePath = path.join(CRATES_DIR, 'target', 'x86_64-pc-windows-msvc', 'release', 'mds.exe'); - copyToDist(exePath, 'mds.exe', 'mds-windows.zip'); + packageWindows(exePath, version); } } +function packageWindows(exePath, version) { + if (!fs.existsSync(exePath)) { + console.error(`ERROR: Binary not found at ${exePath}`); + process.exit(1); + } + + console.log('Packaging Windows distribution...'); + if (!fs.existsSync(DIST_DIR)) { + fs.mkdirSync(DIST_DIR, { recursive: true }); + } + + // Create a staging directory for the zip contents + const stagingDir = path.join(DIST_DIR, 'markdown-editor-windows-staging'); + if (fs.existsSync(stagingDir)) { + fs.rmSync(stagingDir, { recursive: true, force: true }); + } + fs.mkdirSync(stagingDir, { recursive: true }); + + // Copy binary + fs.copyFileSync(exePath, path.join(stagingDir, 'Markdown-Editor.exe')); + + // Copy client assets + copyClientAssets(path.join(stagingDir, 'client')); + + // Create zip archive + const zipPath = path.join(DIST_DIR, 'markdown-editor-windows.zip'); + if (fs.existsSync(zipPath)) { + fs.unlinkSync(zipPath); + } + + console.log('Creating zip archive...'); + try { + if (os.platform() === 'win32') { + exec(`powershell Compress-Archive -Path "${stagingDir}\\*" -DestinationPath "${zipPath}" -Force`); + } else { + exec(`cd "${stagingDir}" && zip -r "${zipPath}" .`); + } + } catch (error) { + console.warn('Warning: Could not create zip archive.'); + } + + // Clean up staging + fs.rmSync(stagingDir, { recursive: true, force: true }); + + console.log('\nāœ“ Build complete!'); + console.log(` Version: ${version}`); + console.log(` Zip file: ${zipPath}`); +} + function buildMacOS(buildType = 'universal') { const version = getVersion(); - console.log(`\nBuilding MDS-Server v${version} for macOS...\n`); + console.log(`\nBuilding Markdown-Editor v${version} for macOS...\n`); + + buildClient(); - // Check if we can build macOS from non-macOS (requires osxcross or similar) if (os.platform() !== 'darwin') { console.warn('WARNING: Building macOS binaries from non-macOS requires special setup.'); console.warn('Consider using GitHub Actions with macOS runners for reliable builds.'); @@ -141,8 +287,7 @@ function buildMacOS(buildType = 'universal') { } let binaryPath; - const appName = 'MDS-Server'; - // const distAppPath = path.join(DIST_DIR, `${appName}.app`); + const appName = 'Markdown-Editor'; if (buildType === 'intel' || buildType === '--intel') { if (!checkRustTarget('x86_64-apple-darwin', 'rustup target add x86_64-apple-darwin')) { @@ -159,7 +304,6 @@ function buildMacOS(buildType = 'universal') { exec('cargo build --release -p md-server --target aarch64-apple-darwin'); binaryPath = path.join(CRATES_DIR, 'target', 'aarch64-apple-darwin', 'release', BINARY_NAME); } else { - // Universal binary if (!checkRustTarget('x86_64-apple-darwin', 'rustup target add x86_64-apple-darwin')) { process.exit(1); } @@ -173,7 +317,6 @@ function buildMacOS(buildType = 'universal') { console.log(' → Building for aarch64-apple-darwin...'); exec('cargo build --release -p md-server --target aarch64-apple-darwin'); - // Create universal binary with lipo (macOS only) if (os.platform() === 'darwin') { console.log(' → Creating universal binary with lipo...'); const universalDir = path.join(CRATES_DIR, 'target', 'universal-apple-darwin', 'release'); @@ -192,10 +335,26 @@ function buildMacOS(buildType = 'universal') { } } - // Create macOS app bundle createMacOSAppBundle(binaryPath, appName, version); } +/** + * Copy pre-generated macOS icon to app bundle resources. + */ +function copyMacOSIcon(resourcesPath) { + const sourceIcnsPath = path.join(CRATES_DIR, 'cli', 'assets', 'AppIcon.icns'); + const destIcnsPath = path.join(resourcesPath, 'AppIcon.icns'); + + if (!fs.existsSync(sourceIcnsPath)) { + console.warn('Warning: AppIcon.icns not found at', sourceIcnsPath); + console.warn('Run: node generate-icons.js --macos to generate it.'); + return; + } + + fs.copyFileSync(sourceIcnsPath, destIcnsPath); + console.log('Using pre-generated AppIcon.icns'); +} + function createMacOSAppBundle(binaryPath, appName, version) { console.log('Creating app bundle...'); @@ -221,6 +380,12 @@ function createMacOSAppBundle(binaryPath, appName, version) { fs.copyFileSync(binaryPath, path.join(macosPath, BINARY_NAME)); fs.chmodSync(path.join(macosPath, BINARY_NAME), 0o755); + // Copy pre-generated app icon + copyMacOSIcon(resourcesPath); + + // Copy client assets into Resources/client/ + copyClientAssets(path.join(resourcesPath, 'client')); + // Create Info.plist const infoPlist = ` @@ -233,7 +398,9 @@ function createMacOSAppBundle(binaryPath, appName, version) { CFBundleName ${appName} CFBundleDisplayName - Markdown Editor Server + Markdown Editor + CFBundleIconFile + AppIcon CFBundleVersion ${version} CFBundleShortVersionString @@ -257,12 +424,9 @@ function createMacOSAppBundle(binaryPath, appName, version) { if (os.platform() === 'darwin') { console.log('Code signing app bundle...'); try { - // Sign the binary first exec(`codesign --force --deep --sign - "${path.join(macosPath, BINARY_NAME)}"`); - // Then sign the entire app bundle exec(`codesign --force --deep --sign - "${appPath}"`); - // Verify the signing worked try { execSync(`codesign --verify --verbose "${appPath}"`, { stdio: 'pipe' }); console.log('āœ“ App bundle signed and verified successfully'); @@ -271,24 +435,23 @@ function createMacOSAppBundle(binaryPath, appName, version) { } } catch (error) { console.warn('Warning: Code signing failed. The app may be blocked by Gatekeeper.'); - console.warn('Users may need to remove quarantine attribute: xattr -d com.apple.quarantine MDS-Server.app'); + console.warn(`Users may need to remove quarantine attribute: xattr -d com.apple.quarantine ${appName}.app`); console.warn('Or right-click the app and select "Open" instead of double-clicking.'); } } // Create zip archive console.log('Creating zip archive...'); - const zipPath = path.join(DIST_DIR, 'mds-macos.zip'); + const zipPath = path.join(DIST_DIR, 'markdown-editor-macos.zip'); if (fs.existsSync(zipPath)) { fs.unlinkSync(zipPath); } - // Use zip command (available on macOS/Linux, or 7z on Windows) try { if (os.platform() === 'win32') { exec(`powershell Compress-Archive -Path "${appPath}" -DestinationPath "${zipPath}" -Force`); } else { - exec(`cd "${DIST_DIR}" && zip -r "mds-macos.zip" "${appName}.app"`); + exec(`cd "${DIST_DIR}" && zip -r "markdown-editor-macos.zip" "${appName}.app"`); } } catch (error) { console.warn('Warning: Could not create zip archive. Install zip utility.'); @@ -300,51 +463,6 @@ function createMacOSAppBundle(binaryPath, appName, version) { console.log(` Zip file: ${zipPath}`); } -function copyToDist(sourcePath, destName, zipName) { - if (!fs.existsSync(sourcePath)) { - console.error(`ERROR: Binary not found at ${sourcePath}`); - process.exit(1); - } - - console.log('Creating dist directory...'); - if (!fs.existsSync(DIST_DIR)) { - fs.mkdirSync(DIST_DIR, { recursive: true }); - } - - const destPath = path.join(DIST_DIR, destName); - - // Clean up previous build - if (fs.existsSync(destPath)) { - fs.unlinkSync(destPath); - } - const zipPath = path.join(DIST_DIR, zipName); - if (fs.existsSync(zipPath)) { - fs.unlinkSync(zipPath); - } - - // Copy binary - console.log('Copying binary to dist...'); - fs.copyFileSync(sourcePath, destPath); - - // Create zip archive - console.log('Creating zip archive...'); - try { - if (os.platform() === 'win32') { - exec(`powershell Compress-Archive -Path "${destPath}" -DestinationPath "${zipPath}" -Force`); - } else { - exec(`cd "${DIST_DIR}" && zip -q "${zipName}" "${destName}"`); - } - } catch (error) { - console.warn('Warning: Could not create zip archive.'); - } - - const version = getVersion(); - console.log('\nāœ“ Build complete!'); - console.log(` Version: ${version}`); - console.log(` Binary: ${destPath}`); - console.log(` Zip file: ${zipPath}`); -} - // Main execution try { if (platform === 'all') { diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index be22bb9..585fe34 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -13,11 +13,14 @@ anyhow = { workspace = true } clap = { workspace = true } dirs = { workspace = true } nix = { workspace = true } +open = "5" sysinfo = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } [target.'cfg(unix)'.dependencies] daemonize = { workspace = true } @@ -25,3 +28,6 @@ daemonize = { workspace = true } [target.'cfg(windows)'.dependencies] winreg = "0.52" windows-service = "0.6" + +[build-dependencies] +winresource = "0.1" diff --git a/crates/cli/assets/AppIcon.icns b/crates/cli/assets/AppIcon.icns new file mode 100644 index 0000000..dca5b77 Binary files /dev/null and b/crates/cli/assets/AppIcon.icns differ diff --git a/crates/cli/assets/icon.ico b/crates/cli/assets/icon.ico new file mode 100644 index 0000000..334fa3d Binary files /dev/null and b/crates/cli/assets/icon.ico differ diff --git a/crates/cli/build.rs b/crates/cli/build.rs new file mode 100644 index 0000000..f0e3618 --- /dev/null +++ b/crates/cli/build.rs @@ -0,0 +1,19 @@ +fn main() { + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os != "windows" { + return; + } + + let icon_path = std::path::Path::new("assets/icon.ico"); + let mut res = winresource::WindowsResource::new(); + res.set("ProductName", "Markdown Editor"); + res.set("FileDescription", "Markdown Editor"); + + if icon_path.exists() { + res.set_icon(icon_path.to_str().unwrap()); + } + + if let Err(e) = res.compile() { + eprintln!("cargo:warning=Failed to compile Windows resources: {e}"); + } +} diff --git a/crates/cli/src/check_server.rs b/crates/cli/src/check_server.rs new file mode 100644 index 0000000..349b6b2 --- /dev/null +++ b/crates/cli/src/check_server.rs @@ -0,0 +1,92 @@ +use std::{fs, path::PathBuf}; + +use anyhow::Context; +use serde::{Deserialize, Serialize}; + +use crate::constants::default_metadata_file; +use crate::utils::get_real_executable_path; + +#[derive(Serialize, Deserialize)] +struct ServerMetadata { + version: String, + executable_path: String, +} + +/// - Check if if the server version is matched, If not, update the server metadata +/// - Return true if the version is matched +/// - Return false if the version is not matched +pub fn check_server() -> Result { + let metadata_file = default_metadata_file(); + let metadata = match fs::read_to_string(&metadata_file) { + Ok(metadata) => metadata, + Err(e) => { + println!( + "No metadata file found: {}, error: {}", + &metadata_file.display(), + e + ); + update_server(None)?; + return Ok(false); + } + }; + + let metadata: ServerMetadata = serde_json::from_str(&metadata).with_context(|| { + format!( + "Failed to parse metadata file: {}", + &metadata_file.display() + ) + })?; + + let current_version = env!("CARGO_PKG_VERSION").to_string(); + if metadata.version != current_version { + println!( + "Server version mismatch, prev: {}, cur {}", + metadata.version, current_version + ); + update_server(Some(&metadata))?; + return Ok(false); + } + + Ok(true) +} + +fn set_metadata() -> Result<(), anyhow::Error> { + let current_version = env!("CARGO_PKG_VERSION").to_string(); + let new_metadata = ServerMetadata { + version: current_version, + executable_path: get_real_executable_path()?.to_string_lossy().to_string(), + }; + + fs::write( + default_metadata_file(), + serde_json::to_string(&new_metadata)?, + ) + .with_context(|| { + format!( + "Failed to write metadata file: {}", + &default_metadata_file().display() + ) + })?; + + Ok(()) +} + +fn update_server(metadata: Option<&ServerMetadata>) -> Result<(), anyhow::Error> { + if let Some(metadata) = metadata { + // stop the previous server under the executable_path + let executable_path = PathBuf::from(&metadata.executable_path); + if executable_path.exists() { + let status = std::process::Command::new(&executable_path) + .arg("stop") + .status()?; + if !status.success() { + return Err(anyhow::anyhow!("Failed to stop previous server")); + } + } + } + + // update the metadata + set_metadata()?; + + Ok(()) +} diff --git a/crates/cli/src/commands/install.rs b/crates/cli/src/commands/install.rs index e509dc7..0a0199a 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -1,173 +1,109 @@ -use std::fs; #[cfg(target_os = "macos")] use std::io::Write; -use std::path::{Path, PathBuf}; +use std::path::Path; use anyhow::{Context, Result}; -use crate::commands::{cmd_start, cmd_uninstall}; -use crate::constants::{DEFAULT_HOST, DEFAULT_PORT}; +use crate::utils::get_real_executable_path; -/// Get the installation directory for the binary -fn get_install_dir() -> PathBuf { - #[cfg(target_os = "macos")] - { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".local/bin") - } - #[cfg(target_os = "windows")] - { - dirs::data_local_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("mds") - } -} +/// Add the binary's current directory to PATH so `mds` can be used as a CLI command. +/// On macOS: creates a symlink at `~/.local/bin/mds` (user-writable, no sudo needed), +/// and ensures `~/.local/bin` is in PATH. Falls back to adding `~/.local/bin` +/// to PATH if symlink creation fails. +/// On Windows: adds the exe directory to the user's PATH registry entry. +pub fn add_to_path() -> Result<()> { + let exe_path = get_real_executable_path().context("Failed to get current executable path")?; -/// Get the path where the binary should be installed -fn get_install_path() -> PathBuf { - #[cfg(target_os = "windows")] - { - get_install_dir().join("mds.exe") - } - #[cfg(not(target_os = "windows"))] - { - get_install_dir().join("mds") - } + add_to_path_inner(&exe_path) } -/// Install the server: copy binary, add to PATH, register autostart, start daemon -pub fn cmd_install() -> Result<()> { - println!("Installing Markdown Editor Server..."); - - let install_path = get_install_path(); - - if install_path.exists() { - println!( - "Previous installation detected at {}. Uninstalling...", - install_path.display() - ); - cmd_uninstall()?; - println!("Previous version removed. Proceeding with fresh install..."); +#[cfg(target_os = "macos")] +fn add_to_path_inner(exe_path: &Path) -> Result<()> { + let home = dirs::home_dir().context("Could not find home directory")?; + let local_bin_dir = home.join(".local").join("bin"); + let local_bin_path = local_bin_dir.join("mds"); + + // Create ~/.local/bin directory if it doesn't exist + if !local_bin_dir.exists() { + std::fs::create_dir_all(&local_bin_dir) + .with_context(|| format!("Failed to create directory: {}", local_bin_dir.display()))?; + println!("Created directory: {}", local_bin_dir.display()); } - let current_exe = std::env::current_exe().context("Failed to get current executable path")?; - let install_dir = get_install_dir(); - - // Step 1: Copy binary to install location - install_binary(¤t_exe, &install_dir, &install_path)?; - - // Step 2: Store the current user's home directory (Windows only) - // This ensures the service uses the same home dir as the installing user - #[cfg(target_os = "windows")] - { - if let Some(home) = dirs::home_dir() { - use crate::utils::store_home_dir; - store_home_dir(&home).ok(); // Best effort - don't fail installation if this fails + // Try to create symlink in ~/.local/bin + match try_create_symlink(exe_path, &local_bin_path) { + Ok(_) => { + println!( + "āœ“ Created symlink: {} -> {}", + local_bin_path.display(), + exe_path.display() + ); + } + Err(e) => { + println!("Warning: Failed to create symlink in ~/.local/bin: {}", e); + println!("Will add ~/.local/bin to PATH instead"); } } - // Step 3: Add to PATH - add_to_path(&install_dir)?; - - // Step 4: Register autostart - register_autostart(&install_path)?; - - // Step 5: Start the daemon - println!("Starting server daemon..."); - cmd_start(true, DEFAULT_HOST.to_string(), DEFAULT_PORT)?; - - println!("\nāœ“ Installation complete!"); - println!(" - Binary installed to: {}", install_path.display()); - println!( - " - Server running on http://{}:{}", - DEFAULT_HOST, DEFAULT_PORT - ); - println!(" - Server will auto-start on login"); - println!(" - Use 'mds' command in a new terminal session"); - - Ok(()) -} - -/// Copy the binary to the install location -fn install_binary(current_exe: &Path, install_dir: &Path, install_path: &Path) -> Result<()> { - // Create install directory if it doesn't exist - fs::create_dir_all(install_dir).with_context(|| { - format!( - "Failed to create install directory: {}", - install_dir.display() - ) - })?; - - // Skip if we're already running from the install location - if current_exe == install_path { - println!("Binary already at install location, overwriting."); - } - - // Copy the binary - fs::copy(current_exe, install_path) - .with_context(|| format!("Failed to copy binary to {}", install_path.display()))?; - - // Make executable on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(install_path)?.permissions(); - perms.set_mode(0o755); - fs::set_permissions(install_path, perms)?; - } - - println!("Binary installed to: {}", install_path.display()); + // Ensure ~/.local/bin is in PATH (whether symlink succeeded or not) + ensure_local_bin_in_path(&local_bin_dir)?; Ok(()) } -/// Add the install directory to PATH +/// Try to create or update a symlink, handling existing symlinks gracefully. +/// Returns Ok(()) on success, Err on failure (including permission errors). #[cfg(target_os = "macos")] -fn add_to_path(install_dir: &Path) -> Result<()> { - let symlink_path = Path::new("/usr/local/bin/mds"); - - // Try to create symlink to /usr/local/bin first - if !symlink_path.exists() { - let install_path = install_dir.join("mds"); - if std::os::unix::fs::symlink(&install_path, symlink_path).is_ok() { +fn try_create_symlink(exe_path: &Path, symlink_path: &Path) -> Result<()> { + if symlink_path.exists() || symlink_path.is_symlink() { + // Check if existing symlink points to the correct target + if let Ok(target) = std::fs::read_link(symlink_path) { + if target == exe_path { + println!( + "āœ“ Symlink already exists and points to correct target: {} -> {}", + symlink_path.display(), + exe_path.display() + ); + return Ok(()); // Already correctly linked + } println!( - "Created symlink: {} -> {}", + "Updating existing symlink: {} -> {}", symlink_path.display(), - install_path.display() + exe_path.display() ); - return Ok(()); } - // Symlink failed (likely no permissions), fall back to shell config - } else { - println!("Symlink already exists at {}", symlink_path.display()); - return Ok(()); + // Remove existing symlink/file to update it + std::fs::remove_file(symlink_path).with_context(|| { + format!( + "Failed to remove existing symlink: {}", + symlink_path.display() + ) + })?; } - // Fall back to adding to shell config - add_to_shell_config(install_dir)?; + // Create new symlink + std::os::unix::fs::symlink(exe_path, symlink_path) + .with_context(|| format!("Failed to create symlink: {}", symlink_path.display()))?; + Ok(()) } -/// Add to shell config files (.zshrc, .bashrc) +/// Ensure ~/.local/bin is in PATH by adding it to shell config files. #[cfg(target_os = "macos")] -fn add_to_shell_config(install_dir: &Path) -> Result<()> { +fn ensure_local_bin_in_path(local_bin_dir: &Path) -> Result<()> { let home = dirs::home_dir().context("Could not find home directory")?; let export_line = format!( - "\n# Markdown Editor Server\nexport PATH=\"{}:$PATH\"\n", - install_dir.display() + "\n# Add ~/.local/bin to PATH if not already present\nexport PATH=\"$HOME/.local/bin:$PATH\"\n" ); - // Add to .zshrc (default shell on modern macOS) let zshrc = home.join(".zshrc"); add_export_to_file(&zshrc, &export_line)?; - // Also add to .bashrc for bash users let bashrc = home.join(".bashrc"); if bashrc.exists() { add_export_to_file(&bashrc, &export_line)?; } - println!("Added {} to PATH in shell config", install_dir.display()); + println!("Ensured {} is in PATH", local_bin_dir.display()); println!("Note: Open a new terminal for the 'mds' command to be available"); Ok(()) @@ -175,14 +111,14 @@ fn add_to_shell_config(install_dir: &Path) -> Result<()> { #[cfg(target_os = "macos")] fn add_export_to_file(file_path: &Path, export_line: &str) -> Result<()> { - let content = fs::read_to_string(file_path).unwrap_or_default(); + let content = std::fs::read_to_string(file_path).unwrap_or_default(); - // Check if already added - if content.contains("Markdown Editor Server") { - return Ok(()); + // Check if ~/.local/bin is already in PATH + if content.contains("$HOME/.local/bin") || content.contains("~/.local/bin") { + return Ok(()); // Already in PATH } - let mut file = fs::OpenOptions::new() + let mut file = std::fs::OpenOptions::new() .create(true) .append(true) .open(file_path) @@ -192,146 +128,63 @@ fn add_export_to_file(file_path: &Path, export_line: &str) -> Result<()> { Ok(()) } -/// Add the install directory to PATH (Windows) #[cfg(target_os = "windows")] -fn add_to_path(install_dir: &Path) -> Result<()> { +fn add_to_path_inner(exe_path: &Path) -> Result<()> { use winreg::RegKey; use winreg::enums::*; + let exe_dir = exe_path + .parent() + .context("Failed to get executable directory")?; + let hkcu = RegKey::predef(HKEY_CURRENT_USER); let env = hkcu .open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) .context("Failed to open Environment registry key")?; let current_path: String = env.get_value("Path").unwrap_or_default(); - let install_dir_str = install_dir.to_string_lossy(); + let exe_dir_str = exe_dir.to_string_lossy(); if current_path .to_lowercase() - .contains(&install_dir_str.to_lowercase()) + .contains(&exe_dir_str.to_lowercase()) { - println!("Install directory already in PATH"); return Ok(()); } let new_path = if current_path.is_empty() { - install_dir_str.to_string() + exe_dir_str.to_string() } else { - format!("{};{}", current_path, install_dir_str) + format!("{};{}", current_path, exe_dir_str) }; env .set_value("Path", &new_path) .context("Failed to update PATH in registry")?; - println!("Added {} to user PATH", install_dir.display()); + println!("Added {} to user PATH", exe_dir.display()); println!("Note: Open a new terminal for the 'mds' command to be available"); - // Broadcast WM_SETTINGCHANGE to notify other applications - broadcast_environment_change(); - Ok(()) } -/// Broadcast environment change on Windows -#[cfg(target_os = "windows")] -fn broadcast_environment_change() { - // The change will take effect in new terminal sessions regardless -} - -/// Register autostart on login (macOS) -#[cfg(target_os = "macos")] -fn register_autostart(exe_path: &Path) -> Result<()> { - let plist_content = format!( - r#" - - - - Label - com.markdown-editor.mds - ProgramArguments - - {} - start - --daemon - - RunAtLoad - - KeepAlive - - -"#, - exe_path.display() - ); - - let launch_agents_dir = dirs::home_dir() - .context("Could not find home directory")? - .join("Library/LaunchAgents"); - - fs::create_dir_all(&launch_agents_dir)?; - - let plist_path = launch_agents_dir.join("com.markdown-editor.mds.plist"); - fs::write(&plist_path, plist_content)?; - - // Load the LaunchAgent immediately - use crate::utils::system_commands; - let success = system_commands::load_launch_agent(plist_path.to_str().unwrap()).unwrap_or(false); - - if success { - println!("Registered autostart via LaunchAgent"); - } else { - println!("LaunchAgent created but could not load immediately"); - } - - Ok(()) -} - -/// Register autostart on login (Windows) -#[cfg(target_os = "windows")] -fn register_autostart(exe_path: &Path) -> Result<()> { - use crate::utils::{CheckAutoStartStatus, is_autostart_registered, system_commands}; - - let exe_path_str = exe_path.to_string_lossy(); - - let auto_start_status = is_autostart_registered()?; - - match auto_start_status { - CheckAutoStartStatus::Registered => { - println!("Service already registered for auto-start"); - return Ok(()); - } - CheckAutoStartStatus::NotRegistered => { - println!("Updating service to auto-start..."); - let status = - system_commands::config_windows_service_start_type("MarkdownEditorServer", "auto")?; - if status.success() { - println!("Service updated to auto-start"); - } else { - anyhow::bail!( - "Failed to update service start type (exit code: {})", - status.code().unwrap_or(-1) - ); - } - } - CheckAutoStartStatus::NotExist => { - // Service doesn't exist, create it with auto-start - println!("Creating service with auto-start..."); - let created = system_commands::create_windows_service( - "MarkdownEditorServer", - &exe_path_str, - "Markdown Editor Server", - "auto", - )?; - if created { - println!("Service created with auto-start"); - } else { - anyhow::bail!("Failed to create service"); - } - } - CheckAutoStartStatus::Error => { - anyhow::bail!("Error checking autostart registration status"); - } - } - - Ok(()) -} +// #[cfg(target_os = "windows")] +// pub fn delete_windows_service(service_name: &str) -> Result<()> { +// // Delete the service +// match system_commands::delete_windows_service(service_name) { +// Ok(s) if s.success() => { +// println!("Removed service"); +// } +// Ok(s) => { +// println!( +// "Warning: Failed to delete service (exit code: {}), it may need manual removal", +// s.code().unwrap_or(-1) +// ); +// } +// Err(e) => { +// println!("Warning: Failed to delete service: {}", e); +// } +// } + +// Ok(()) +// } diff --git a/crates/cli/src/commands/location.rs b/crates/cli/src/commands/location.rs index fbd7b15..a20f522 100644 --- a/crates/cli/src/commands/location.rs +++ b/crates/cli/src/commands/location.rs @@ -6,13 +6,18 @@ use crate::utils::app_data_dir; /// Show the current executable location and app data location pub fn cmd_location() -> Result<()> { - // Get the current executable path + // Get the current executable path (may be a symlink) let exe_path = env::current_exe().context("Failed to get current executable path")?; + // Resolve symlinks to get the actual path + let actual_path = + std::fs::canonicalize(&exe_path).context("Failed to resolve executable path")?; + // Get the app data directory let app_data_path = app_data_dir(); - println!("Executable location: {}", exe_path.display()); + println!("Executable location (symlink): {}", exe_path.display()); + println!("Executable location (actual): {}", actual_path.display()); println!("App data location: {}", app_data_path.display()); Ok(()) diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 683fc81..035ca3c 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -4,12 +4,10 @@ mod logs; mod start; mod status; mod stop; -mod uninstall; -pub use install::cmd_install; +pub use install::add_to_path; pub use location::cmd_location; pub use logs::{cmd_logs_clear, cmd_logs_view}; pub use start::cmd_start; pub use status::cmd_status; pub use stop::cmd_stop; -pub use uninstall::cmd_uninstall; diff --git a/crates/cli/src/commands/start.rs b/crates/cli/src/commands/start.rs index d3e77a2..6ab1616 100644 --- a/crates/cli/src/commands/start.rs +++ b/crates/cli/src/commands/start.rs @@ -9,6 +9,28 @@ use crate::{ utils::{is_process_running, read_pid_file}, }; +/// Resolve the bundled client directory relative to the current executable. +/// - macOS .app bundle: `{exe_dir}/../Resources/client/` +/// - Windows / general: `{exe_dir}/client/` +fn resolve_client_dir() -> Option { + let exe_path = std::env::current_exe().ok()?; + let exe_dir = exe_path.parent()?; + + // macOS .app bundle: binary is at Contents/MacOS/mds, client at Contents/Resources/client + let macos_client = exe_dir.join("../Resources/client"); + if macos_client.is_dir() { + return Some(macos_client.canonicalize().unwrap_or(macos_client)); + } + + // General case: client/ alongside the binary + let sibling_client = exe_dir.join("client"); + if sibling_client.is_dir() { + return Some(sibling_client.canonicalize().unwrap_or(sibling_client)); + } + + None +} + /// Start the server (foreground or daemon mode) pub fn cmd_start(daemon: bool, host: String, port: u16) -> Result<()> { let pid_file = default_pid_file(); @@ -39,12 +61,18 @@ pub fn cmd_start(daemon: bool, host: String, port: u16) -> Result<()> { fn start_foreground(host: String, port: u16) -> Result<()> { println!("Starting server on {}:{}...", host, port); + let client_dir = resolve_client_dir(); + if let Some(ref dir) = client_dir { + println!("Serving client from {}", dir.display()); + } + let config = ServerConfig { host, port, log_dir: default_log_dir(), log_to_terminal: true, editor_settings_file: default_editor_settings_file(), + client_dir, }; let rt = tokio::runtime::Runtime::new()?; @@ -75,16 +103,17 @@ fn start_daemon(host: String, port: u16, pid_file: &PathBuf) -> Result<()> { .chown_pid_file(true) .working_directory("."); - // Daemonize - after this, we ARE the daemon process match daemonize.start() { Ok(_) => { - // Now running as daemon - start the server + let client_dir = resolve_client_dir(); + let config = ServerConfig { host, port, log_dir: default_log_dir(), log_to_terminal: false, editor_settings_file: default_editor_settings_file(), + client_dir, }; let rt = tokio::runtime::Runtime::new()?; @@ -103,23 +132,27 @@ fn start_daemon(host: String, port: u16, pid_file: &PathBuf) -> Result<()> { /// Start the server as a Windows service #[cfg(target_os = "windows")] fn start_daemon(host: String, port: u16, pid_file: &PathBuf) -> Result<()> { - use crate::utils::{get_and_write_service_pid, system_commands}; + use crate::utils::{get_and_write_service_pid, get_real_executable_path, system_commands}; println!("Starting server service on {}:{}...", host, port); - // Check if service exists let service_exists = system_commands::query_windows_service("MarkdownEditorServer").unwrap_or(false); - if !service_exists { - // Service not installed, create it - println!("Service not installed, registering service..."); - let exe_path = std::env::current_exe()?; + let exe_path = get_real_executable_path()?; + if service_exists { + use crate::utils::system_commands::{stop_windows_service, update_bin_path_windows_service}; + + println!("Service already registered, update bin path."); + update_bin_path_windows_service("MarkdownEditorServer", &exe_path.to_string_lossy())?; + println!("Restarting service to apply changes..."); + stop_windows_service("MarkdownEditorServer")?; + } else { let created = system_commands::create_windows_service( "MarkdownEditorServer", &exe_path.to_string_lossy(), "Markdown Editor Server", - "demand", // Manual start + "demand", )?; if !created { @@ -128,17 +161,14 @@ fn start_daemon(host: String, port: u16, pid_file: &PathBuf) -> Result<()> { println!("Service registered."); } - // Start the Windows service let status = system_commands::start_windows_service("MarkdownEditorServer")?; if status.success() { println!("Server service started."); - // Get the service PID and write to file get_and_write_service_pid(pid_file)?; } else { if status.code() == Some(1056) { println!("Server service is already running."); - // Get the service PID and write to file get_and_write_service_pid(pid_file)?; } else { anyhow::bail!( diff --git a/crates/cli/src/commands/status.rs b/crates/cli/src/commands/status.rs index 2020978..94f1622 100644 --- a/crates/cli/src/commands/status.rs +++ b/crates/cli/src/commands/status.rs @@ -11,20 +11,17 @@ use crate::{ pub fn cmd_status() -> Result<()> { #[cfg(target_os = "windows")] { - // On Windows, first check if the service is running use crate::utils::get_service_pid; if let Some(pid) = get_service_pid() { if is_process_running(pid) { println!("Server service is running with PID {}", pid); } else { - // Service exists but not running println!("Server service exists but is not running"); } } } - // Fallback to PID file check (Unix or if service check failed) let pid_file = default_pid_file(); match read_pid_file(&pid_file) { @@ -41,23 +38,5 @@ pub fn cmd_status() -> Result<()> { } } - // Show autostart status (only on Windows for now) - #[cfg(target_os = "windows")] - { - use crate::utils::{CheckAutoStartStatus, is_autostart_registered}; - - match is_autostart_registered() { - Ok(CheckAutoStartStatus::Registered) => { - println!("Server is registered for auto-start"); - } - Ok(CheckAutoStartStatus::NotExist) => { - println!("Server does not exist"); - } - _ => { - println!("Server is not registered for auto-start"); - } - } - } - Ok(()) } diff --git a/crates/cli/src/commands/uninstall.rs b/crates/cli/src/commands/uninstall.rs deleted file mode 100644 index 05bf6c6..0000000 --- a/crates/cli/src/commands/uninstall.rs +++ /dev/null @@ -1,199 +0,0 @@ -#[cfg(target_os = "macos")] -use std::path::Path; - -use std::fs; - -use std::path::PathBuf; - -use anyhow::{Context, Result}; - -use crate::commands::cmd_stop; - -use crate::utils::remove_file_with_retry; - -/// Get the installation directory for the binary -fn get_install_dir() -> PathBuf { - #[cfg(target_os = "macos")] - { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".local/bin") - } - #[cfg(target_os = "windows")] - { - dirs::data_local_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("mds") - } -} - -/// Get the path where the binary is installed -fn get_install_path() -> PathBuf { - #[cfg(target_os = "windows")] - { - get_install_dir().join("mds.exe") - } - #[cfg(not(target_os = "windows"))] - { - get_install_dir().join("mds") - } -} - -/// Uninstall the server: stop daemon, remove autostart, remove from PATH, delete binary -pub fn cmd_uninstall() -> Result<()> { - println!("Uninstalling Markdown Editor Server..."); - - // Step 1: Stop the running daemon - println!("Stopping server..."); - let _ = cmd_stop(); // Ignore errors if not running - - // Step 2: Remove autostart - remove_autostart()?; - - // Step 3: Remove from PATH - remove_from_path()?; - - // Step 4: Remove the binary - remove_binary()?; - - // Step 5: Clean up app data (optional - keep user settings) - // We don't remove ~/.md-server to preserve user settings - - println!("\nāœ“ Uninstallation complete!"); - println!(" Note: User settings in ~/.md-server were preserved."); - println!(" To remove all data, delete ~/.md-server manually."); - - Ok(()) -} - -/// Remove the installed binary -fn remove_binary() -> Result<()> { - let install_path = get_install_path(); - - if !install_path.exists() { - println!("Binary not found at {}", install_path.display()); - return Ok(()); - } - - // On Windows, try to remove read-only attributes first - #[cfg(target_os = "windows")] - { - use crate::utils::remove_readonly_attributes; - remove_readonly_attributes(&install_path)?; - } - - remove_file_with_retry(&install_path)?; - - // Try to remove the install directory if empty - let install_dir = get_install_dir(); - if install_dir - .read_dir() - .map(|mut d| d.next().is_none()) - .unwrap_or(false) - { - let _ = fs::remove_dir(&install_dir); - } - - Ok(()) -} - -/// Remove autostart registration (macOS) -#[cfg(target_os = "macos")] -fn remove_autostart() -> Result<()> { - use crate::utils::system_commands; - - let plist_path = dirs::home_dir() - .context("Could not find home directory")? - .join("Library/LaunchAgents/com.markdown-editor.mds.plist"); - - if plist_path.exists() { - // Unload the LaunchAgent first - let _ = system_commands::unload_launch_agent(plist_path.to_str().unwrap()); - - fs::remove_file(&plist_path)?; - println!("Removed LaunchAgent"); - } - - Ok(()) -} - -/// Remove autostart registration (Windows) -#[cfg(target_os = "windows")] -fn remove_autostart() -> Result<()> { - use crate::utils::system_commands; - - // Delete the service - match system_commands::delete_windows_service("MarkdownEditorServer") { - Ok(s) if s.success() => { - println!("Removed service"); - } - Ok(s) => { - println!( - "Warning: Failed to delete service (exit code: {}), it may need manual removal", - s.code().unwrap_or(-1) - ); - } - Err(e) => { - println!("Warning: Failed to delete service: {}", e); - } - } - - Ok(()) -} - -/// Remove from PATH (macOS) -#[cfg(target_os = "macos")] -fn remove_from_path() -> Result<()> { - use crate::utils::remove_from_shell_config; - - // Remove symlink if it exists - let symlink_path = Path::new("/usr/local/bin/mds"); - if symlink_path.exists() || symlink_path.is_symlink() { - match fs::remove_file(symlink_path) { - Ok(_) => println!("Removed symlink: {}", symlink_path.display()), - Err(_) => println!("Could not remove symlink (may need sudo)"), - } - } - - // Remove from shell configs - let home = dirs::home_dir().context("Could not find home directory")?; - let install_dir = get_install_dir(); - - remove_from_shell_config(&home.join(".zshrc"), &install_dir)?; - remove_from_shell_config(&home.join(".bashrc"), &install_dir)?; - - Ok(()) -} - -/// Remove from PATH (Windows) -#[cfg(target_os = "windows")] -fn remove_from_path() -> Result<()> { - use winreg::RegKey; - use winreg::enums::*; - - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - let env = hkcu - .open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE) - .context("Failed to open Environment registry key")?; - - let current_path: String = env.get_value("Path").unwrap_or_default(); - let install_dir = get_install_dir(); - let install_dir_str = install_dir.to_string_lossy(); - - if current_path - .to_lowercase() - .contains(&install_dir_str.to_lowercase()) - { - // Remove the install directory from PATH - let new_path: String = current_path - .split(';') - .filter(|p| !p.eq_ignore_ascii_case(&install_dir_str)) - .collect::>() - .join(";"); - - env.set_value("Path", &new_path)?; - println!("Removed {} from PATH", install_dir.display()); - } - - Ok(()) -} diff --git a/crates/cli/src/constants.rs b/crates/cli/src/constants.rs index 3d58167..a68cb1c 100644 --- a/crates/cli/src/constants.rs +++ b/crates/cli/src/constants.rs @@ -17,6 +17,12 @@ pub fn default_pid_file() -> PathBuf { app_data_dir().join("mds.pid") } +/// The file that stores the metadata of the server +/// Including version, executable path +pub fn default_metadata_file() -> PathBuf { + app_data_dir().join("mds.metadata.json") +} + pub fn default_editor_settings_file() -> PathBuf { app_data_dir().join("editor-settings.json") } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 4bb75c8..1f0494d 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,17 +1,21 @@ +mod check_server; mod commands; mod constants; mod utils; +use std::net::{TcpListener, TcpStream}; + use anyhow::Result; use clap::{Parser, Subcommand}; use commands::{ - cmd_install, cmd_location, cmd_logs_clear, cmd_logs_view, cmd_start, cmd_status, cmd_stop, - cmd_uninstall, + add_to_path, cmd_location, cmd_logs_clear, cmd_logs_view, cmd_start, cmd_status, cmd_stop, }; -use constants::DEFAULT_PORT; +use constants::{DEFAULT_HOST, DEFAULT_PORT}; -use crate::constants::DEFAULT_HOST; +use crate::check_server::check_server; +use crate::constants::default_pid_file; +use crate::utils::{get_real_executable_path, is_process_running, read_pid_file}; #[cfg(target_os = "windows")] use std::ffi::OsString; @@ -57,9 +61,6 @@ fn my_service_main(_arguments: Vec) { }) .unwrap(); - // Run the server - // Compute paths in the service thread's context (they may differ from CLI user context) - // This ensures the service uses the same paths as intended let log_dir = crate::constants::default_log_dir(); let editor_settings_file = crate::constants::default_editor_settings_file(); @@ -69,6 +70,7 @@ fn my_service_main(_arguments: Vec) { log_dir, log_to_terminal: false, editor_settings_file, + client_dir: None, }; let rt = tokio::runtime::Runtime::new().unwrap(); @@ -76,10 +78,8 @@ fn my_service_main(_arguments: Vec) { server::run_server(config).await.unwrap(); }); - // Wait for shutdown signal let _ = shutdown_rx.recv(); - // Stop the server rt.shutdown_timeout(Duration::from_secs(5)); status_handle @@ -129,12 +129,6 @@ enum Commands { /// Check if the server is running Status, - /// Install the server (copy binary, add to PATH, register autostart) - Install, - - /// Uninstall the server (remove binary, PATH entry, and autostart) - Uninstall, - /// View or manage server logs Logs { #[command(subcommand)] @@ -156,13 +150,28 @@ enum LogsCmd { Clear, } +/// Find an available port starting from the preferred port. +fn find_available_port(host: &str, preferred: u16) -> Result { + if TcpListener::bind((host, preferred)).is_ok() { + return Ok(preferred); + } + for port in (preferred + 1)..=(preferred + 100) { + if TcpListener::bind((host, port)).is_ok() { + return Ok(port); + } + } + anyhow::bail!( + "No available port found in range {}-{}", + preferred, + preferred + 100 + ); +} + fn main() -> Result<()> { - // On Windows, check if running as a service #[cfg(target_os = "windows")] { use windows_service::service_dispatcher; - // If dispatched as service, run service_main if let Err(_) = service_dispatcher::start("MarkdownEditorServer", ffi_service_main) { // Not running as service, proceed with CLI } @@ -170,18 +179,69 @@ fn main() -> Result<()> { let cli = Cli::parse(); - // Handle location flag first if cli.location { cmd_location()?; return Ok(()); } match cli.command { + // Quick launch: add to PATH, check if running, start daemon, open browser None => { - // Install anyway if exists, just overwrite - cmd_install()?; + println!("Run `mds -h` for more information."); + + let _ = add_to_path(); // best-effort, don't fail if PATH update fails + + let is_matched_server = check_server()?; + if is_matched_server { + let pid_file = default_pid_file(); + if let Some(pid) = read_pid_file(&pid_file) { + if is_process_running(pid) { + println!("Server is already running with PID {}", pid); + let url = format!("http://{}:{}/", DEFAULT_HOST, DEFAULT_PORT); + if open::that(&url).is_err() { + println!("Open {} in your browser", url); + } + return Ok(()); + } + } + } + + let port = find_available_port(DEFAULT_HOST, DEFAULT_PORT)?; + + // Spawn daemon as a separate process so this process survives to open the browser. + // Calling cmd_start(daemon=true) directly would daemonize *this* process (the parent + // is killed by the fork), so the browser-opening code below would never execute. + let exe = get_real_executable_path()?; + let _child = std::process::Command::new(&exe) + .args([ + "start", + "--daemon", + "--host", + DEFAULT_HOST, + "--port", + &port.to_string(), + ]) + .spawn() + .map_err(|e| anyhow::anyhow!("Failed to spawn daemon process: {}", e))?; + + // Poll until the server is reachable (up to ~3 seconds) + let url = format!("http://{}:{}/", DEFAULT_HOST, port); + let mut ready = false; + for _ in 0..6 { + std::thread::sleep(std::time::Duration::from_millis(500)); + if TcpStream::connect((DEFAULT_HOST, port)).is_ok() { + ready = true; + break; + } + } + + if !ready { + println!("Warning: server may not be ready yet"); + } - println!("Run 'mds -h' for more information."); + if open::that(&url).is_err() { + println!("Open {} in your browser", url); + } } Some(Commands::Start { daemon, host, port }) => { cmd_start(daemon, host, port)?; @@ -192,12 +252,6 @@ fn main() -> Result<()> { Some(Commands::Status) => { cmd_status()?; } - Some(Commands::Install) => { - cmd_install()?; - } - Some(Commands::Uninstall) => { - cmd_uninstall()?; - } Some(Commands::Logs { cmd, tail, follow }) => match cmd { Some(LogsCmd::Clear) => { cmd_logs_clear()?; diff --git a/crates/cli/src/utils/mod.rs b/crates/cli/src/utils/mod.rs index 980c68a..22710e5 100644 --- a/crates/cli/src/utils/mod.rs +++ b/crates/cli/src/utils/mod.rs @@ -1,6 +1,4 @@ use std::fs; -#[cfg(target_os = "macos")] -use std::path::Path; use std::path::PathBuf; use sysinfo::System; @@ -8,13 +6,11 @@ use sysinfo::System; pub mod system_commands; /// Get the stored home directory (for service runs) -/// This is used by the Windows service to use the same home directory as the user who installed it #[cfg(target_os = "windows")] fn get_stored_home_dir() -> Option { use winreg::RegKey; use winreg::enums::*; - // Access HKEY_LOCAL_MACHINE for system-wide service access let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); let mds_key = hklm.open_subkey("Software\\MarkdownEditorServer").ok()?; @@ -23,34 +19,13 @@ fn get_stored_home_dir() -> Option { if path.exists() { Some(path) } else { None } } -/// Store the home directory for the service to use later -#[cfg(target_os = "windows")] -pub fn store_home_dir(home_dir: &PathBuf) -> std::io::Result<()> { - use winreg::RegKey; - use winreg::enums::*; - - // Store in HKEY_LOCAL_MACHINE so the service (running as SYSTEM) can access it - let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); - let (mds_key, _disp) = hklm - .create_subkey("Software\\MarkdownEditorServer") - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; - - mds_key - .set_value("HomeDir", &home_dir.to_string_lossy().as_ref()) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; - - Ok(()) -} - /// Get the app data directory in user's home (for release builds) -/// Falls back to "." if home directory cannot be determined pub fn app_data_dir() -> PathBuf { #[cfg(not(debug_assertions))] let data_path = PathBuf::from(".md-server"); #[cfg(debug_assertions)] let data_path = PathBuf::from(".md-server-dev"); - // On Windows, try to use the stored home directory first (for service runs) #[cfg(target_os = "windows")] { if let Some(home) = get_stored_home_dir() { @@ -77,91 +52,6 @@ pub fn is_process_running(pid: u32) -> bool { sys.process(sysinfo::Pid::from_u32(pid)).is_some() } -/// Remove the export line from a shell config file -#[cfg(target_os = "macos")] -pub fn remove_from_shell_config( - file_path: &Path, - _install_dir: &Path, -) -> Result<(), anyhow::Error> { - use std::io::Write; - use std::io::{BufRead, BufReader}; - - if !file_path.exists() { - return Ok(()); - } - - let file = fs::File::open(file_path)?; - let reader = BufReader::new(file); - let mut lines: Vec = Vec::new(); - let mut found = false; - let mut skip_next = false; - - for line in reader.lines() { - let line = line?; - - // Skip the comment and the export line - if line.contains("# Markdown Editor Server") { - found = true; - skip_next = true; - continue; - } - - if skip_next && line.starts_with("export PATH=") && line.contains(".local/bin") { - skip_next = false; - continue; - } - - skip_next = false; - lines.push(line); - } - - if found { - let mut file = fs::File::create(file_path)?; - for line in lines { - writeln!(file, "{}", line)?; - } - println!("Removed PATH entry from {}", file_path.display()); - } - - Ok(()) -} - -#[cfg(target_os = "windows")] -pub enum CheckAutoStartStatus { - Registered, - NotRegistered, - NotExist, - Error, -} -/// Check if the service is registered for autostart (Windows) -#[cfg(target_os = "windows")] -pub fn is_autostart_registered() -> Result { - use crate::utils::system_commands::query_windows_service_config; - - let query_config = query_windows_service_config("MarkdownEditorServer"); - - let service_exists = query_config - .as_ref() - .map(|output| output.status.success()) - .unwrap_or(false); - - if service_exists { - // Check if it's already set to auto start - if let Ok(output) = &query_config { - let stdout = String::from_utf8_lossy(&output.stdout); - if stdout.contains("START_TYPE : 2 AUTO_START") { - return Ok(CheckAutoStartStatus::Registered); - } - } else { - return Ok(CheckAutoStartStatus::Error); - } - - return Ok(CheckAutoStartStatus::NotRegistered); - } - - Ok(CheckAutoStartStatus::NotExist) -} - #[cfg(target_os = "windows")] /// Get the PID of the Windows service if it's running pub fn get_service_pid() -> Option { @@ -175,30 +65,16 @@ pub fn get_service_pid() -> Option { let stdout = String::from_utf8_lossy(&output.stdout); - // Check if the service is actually running let is_running = stdout.contains("STATE : 4 RUNNING"); if !is_running { return None; } - // Parse the PID from the output - // The output format is like: - // SERVICE_NAME: MarkdownEditorServer - // TYPE : 10 WIN32_OWN_PROCESS - // STATE : 4 RUNNING - // WIN32_EXIT_CODE : 0 (0x0) - // SERVICE_EXIT_CODE : 0 (0x0) - // CHECKPOINT : 0x0 - // WAIT_HINT : 0x0 - // PID : 1234 - // FLAGS : - for line in stdout.lines() { if line.trim().starts_with("PID") { if let Some(pid_str) = line.split(':').nth(1) { if let Ok(pid) = pid_str.trim().parse::() { if pid > 0 { - // PID 0 is invalid return Some(pid); } } @@ -223,7 +99,6 @@ pub fn get_and_write_service_pid(pid_file: &PathBuf) -> Result<(), anyhow::Error pid_file.display() ); } - // Write PID to file match fs::write(pid_file, pid.to_string()) { Ok(_) => { println!("Wrote service PID {} to file {}", pid, pid_file.display()); @@ -241,92 +116,9 @@ pub fn get_and_write_service_pid(pid_file: &PathBuf) -> Result<(), anyhow::Error Ok(()) } -/// Remove read-only attributes from a file on Windows using attrib command -#[cfg(target_os = "windows")] -pub fn remove_readonly_attributes(path: &PathBuf) -> Result<(), anyhow::Error> { - use crate::utils::system_commands::remove_readonly_attribute; - use std::os::windows::fs::MetadataExt; - - // Check if file has read-only attribute - let metadata = match fs::metadata(path) { - Ok(m) => m, - Err(_) => return Ok(()), // If we can't read metadata, continue anyway - }; - - let attributes = metadata.file_attributes(); - const FILE_ATTRIBUTE_READONLY: u32 = 0x1; - - if (attributes & FILE_ATTRIBUTE_READONLY) != 0 { - // Try using attrib command to remove read-only flag - // Use the path as-is; Command handles proper escaping - let path_str = path.to_string_lossy().to_string(); - let success = remove_readonly_attribute(&path_str).unwrap_or(false); - - if success { - println!("Removed read-only attribute from {}", path.display()); - } else { - println!( - "Warning: Could not remove read-only attribute from {} (attrib command failed)", - path.display() - ); - } - } - - Ok(()) -} - -pub fn remove_file_with_retry(path: &PathBuf) -> Result<(), anyhow::Error> { - use std::fs; - - // Try to remove the file with retry logic (useful if file is temporarily locked) - let mut attempts = 3; - let mut last_error = None; - - while attempts > 0 { - match fs::remove_file(path) { - Ok(_) => { - println!("Removed binary: {}", path.display()); - last_error = None; - break; - } - Err(e) => { - last_error = Some(e); - attempts -= 1; - if attempts > 0 { - // Wait a bit before retrying (file might be locked by antivirus or system) - println!( - "Warning: Failed to remove binary (attempts remaining: {}). Error: {}", - attempts, - last_error.as_ref().unwrap() - ); - std::thread::sleep(std::time::Duration::from_millis(500)); - } - } - } - } - - // If all attempts failed, print detailed error - if let Some(e) = last_error { - eprintln!("\nāŒ Failed to remove binary after multiple attempts:"); - eprintln!(" Path: {}", path.display()); - eprintln!(" Error: {}", e); - eprintln!(" Error kind: {:?}", e.kind()); - - #[cfg(target_os = "windows")] - { - eprintln!("\nšŸ’” Suggestions for Windows:"); - eprintln!(" 1. Make sure the server is fully stopped (wait a few seconds)"); - eprintln!(" 2. Check if Windows Defender or antivirus is scanning the file"); - eprintln!(" 3. Try running as Administrator"); - eprintln!(" 4. Manually delete the file: {}", path.display()); - eprintln!(" 5. Check if any process is using the file (use Process Explorer)"); - } - - return Err(anyhow::anyhow!(format!( - "Failed to remove binary: {}. See error details above.", - path.display() - ))); - } - - return Ok(()); +/// Get the real path for the symlink case +pub fn get_real_executable_path() -> Result { + let exe_path = std::env::current_exe()?; + let actual_path = std::fs::canonicalize(&exe_path)?; + Ok(actual_path) } diff --git a/crates/cli/src/utils/system_commands.rs b/crates/cli/src/utils/system_commands.rs index d55c947..e733b9e 100644 --- a/crates/cli/src/utils/system_commands.rs +++ b/crates/cli/src/utils/system_commands.rs @@ -1,10 +1,7 @@ -use anyhow::Result; -use std::process::Command; - /// Query Windows service status #[cfg(target_os = "windows")] -pub fn query_windows_service(service_name: &str) -> Result { - let query_status = Command::new("sc.exe") +pub fn query_windows_service(service_name: &str) -> anyhow::Result { + let query_status = std::process::Command::new("sc.exe") .args(["query", service_name]) .status()?; @@ -18,8 +15,8 @@ pub fn create_windows_service( exe_path: &str, display_name: &str, start_type: &str, -) -> Result { - let create_status = Command::new("sc.exe") +) -> anyhow::Result { + let create_status = std::process::Command::new("sc.exe") .args([ "create", service_name, @@ -35,102 +32,66 @@ pub fn create_windows_service( Ok(create_status.success()) } -/// Start a Windows service #[cfg(target_os = "windows")] -pub fn start_windows_service(service_name: &str) -> Result { - let status = Command::new("sc.exe") - .args(["start", service_name]) +pub fn update_bin_path_windows_service(service_name: &str, exe_path: &str) -> anyhow::Result { + let update_status = std::process::Command::new("sc.exe") + .args([ + "config", + service_name, + "binPath=", + &format!("\"{}\"", exe_path), + ]) .status()?; - Ok(status) + Ok(update_status.success()) } -/// Stop a Windows service +/// Start a Windows service #[cfg(target_os = "windows")] -pub fn stop_windows_service(service_name: &str) -> Result { - let status = Command::new("sc.exe") - .args(["stop", service_name]) +pub fn start_windows_service(service_name: &str) -> anyhow::Result { + let status = std::process::Command::new("sc.exe") + .args(["start", service_name]) .status()?; Ok(status) } -/// Delete a Windows service +/// Stop a Windows service #[cfg(target_os = "windows")] -pub fn delete_windows_service(service_name: &str) -> Result { - let status = Command::new("sc.exe") - .args(["delete", service_name]) +pub fn stop_windows_service(service_name: &str) -> anyhow::Result { + let status = std::process::Command::new("sc.exe") + .args(["stop", service_name]) .status()?; Ok(status) } -/// Query Windows service configuration +/// Delete a Windows service #[cfg(target_os = "windows")] -pub fn query_windows_service_config(service_name: &str) -> Result { - let output = Command::new("sc.exe").args(["qc", service_name]).output()?; +// pub fn delete_windows_service(service_name: &str) -> anyhow::Result { +// let status = std::process::Command::new("sc.exe") +// .args(["delete", service_name]) +// .status()?; - Ok(output) -} +// Ok(status) +// } /// Query extended Windows service information (including PID) #[cfg(target_os = "windows")] -pub fn query_windows_service_ex(service_name: &str) -> Result { - let output = Command::new("sc.exe") +pub fn query_windows_service_ex(service_name: &str) -> anyhow::Result { + let output = std::process::Command::new("sc.exe") .args(["queryex", service_name]) .output()?; Ok(output) } -/// Configure Windows service start type -#[cfg(target_os = "windows")] -pub fn config_windows_service_start_type( - service_name: &str, - start_type: &str, -) -> Result { - let status = Command::new("sc.exe") - .args(["config", service_name, "start=", start_type]) - .status()?; - - Ok(status) -} - -/// Load a macOS LaunchAgent -#[cfg(target_os = "macos")] -#[allow(dead_code)] // Used in install.rs on macOS -pub fn load_launch_agent(plist_path: &str) -> Result { - let status = Command::new("launchctl") - .args(["load", plist_path]) - .status()?; - - Ok(status.success()) -} - -/// Unload a macOS LaunchAgent -#[cfg(target_os = "macos")] -pub fn unload_launch_agent(plist_path: &str) -> Result { - let status = Command::new("launchctl") - .args(["unload", plist_path]) - .status()?; - - Ok(status.success()) -} - /// Kill a Windows process by PID #[cfg(target_os = "windows")] -pub fn kill_windows_process(pid: u32) -> Result { - let output = Command::new("taskkill") +pub fn kill_windows_process(pid: u32) -> anyhow::Result { + let output = std::process::Command::new("taskkill") .args(["/PID", &pid.to_string(), "/F"]) .output()?; Ok(output) } - -/// Remove read-only attribute from a Windows file -#[cfg(target_os = "windows")] -pub fn remove_readonly_attribute(path: &str) -> Result { - let status = Command::new("attrib").args(["-R", path]).status()?; - - Ok(status.success()) -} diff --git a/crates/generate-icons.js b/crates/generate-icons.js new file mode 100755 index 0000000..34c4a83 --- /dev/null +++ b/crates/generate-icons.js @@ -0,0 +1,176 @@ +#!/usr/bin/env node +/* eslint-disable @typescript-eslint/no-var-requires */ + +/** + * Standalone script to generate macOS and Windows icons from SVG/PNG source. + * Usage: node generate-icons.js [--macos] [--windows] [--all] + * --macos: Generate macOS icon only + * --windows: Generate Windows icon only + * --all: Generate both icons (default) + */ + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const SCRIPT_DIR = __dirname; +const PROJECT_ROOT = path.dirname(SCRIPT_DIR); +const CRATES_DIR = SCRIPT_DIR; + +// Parse arguments +const args = process.argv.slice(2); +const generateMacOS = args.includes('--macos') || args.includes('--all') || args.length === 0; +const generateWindows = args.includes('--windows') || args.includes('--all') || args.length === 0; + +/** + * Generate a .ico file for the Windows exe from logo.svg. + * ICO format wraps a PNG image (supported since Windows Vista). + */ +function generateWindowsIcon() { + const svgPath = path.join(PROJECT_ROOT, 'client', 'public', 'logo.svg'); + const icoDir = path.join(CRATES_DIR, 'cli', 'assets'); + const icoPath = path.join(icoDir, 'icon.ico'); + + if (!fs.existsSync(svgPath)) { + console.error('ERROR: logo.svg not found at', svgPath); + process.exit(1); + } + + console.log('Generating Windows icon...'); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mds-icon-')); + const pngPath = path.join(tmpDir, 'icon_256.png'); + + try { + try { + execSync(`rsvg-convert -w 256 -h 256 "${svgPath}" -o "${pngPath}"`, { stdio: 'pipe' }); + } catch { + if (os.platform() === 'darwin') { + execSync(`qlmanage -t -s 256 -o "${tmpDir}" "${svgPath}"`, { stdio: 'pipe' }); + const qlOutput = path.join(tmpDir, 'logo.svg.png'); + if (fs.existsSync(qlOutput)) { + fs.renameSync(qlOutput, pngPath); + } + } else { + console.error('ERROR: Could not convert SVG to PNG. Install librsvg (brew install librsvg) or use macOS.'); + process.exit(1); + } + } + + if (!fs.existsSync(pngPath)) { + console.error('ERROR: Could not convert SVG to PNG for Windows icon.'); + process.exit(1); + } + + const pngData = fs.readFileSync(pngPath); + + // ICO = ICONDIR (6 bytes) + ICONDIRENTRY (16 bytes) + PNG data + const header = Buffer.alloc(6); + header.writeUInt16LE(0, 0); + header.writeUInt16LE(1, 2); + header.writeUInt16LE(1, 4); + + const entry = Buffer.alloc(16); + entry.writeUInt8(0, 0); // width 256 → stored as 0 + entry.writeUInt8(0, 1); // height 256 → stored as 0 + entry.writeUInt8(0, 2); + entry.writeUInt8(0, 3); + entry.writeUInt16LE(1, 4); + entry.writeUInt16LE(32, 6); + entry.writeUInt32LE(pngData.length, 8); + entry.writeUInt32LE(22, 12); // offset = 6 + 16 + + if (!fs.existsSync(icoDir)) { + fs.mkdirSync(icoDir, { recursive: true }); + } + fs.writeFileSync(icoPath, Buffer.concat([header, entry, pngData])); + console.log('āœ“ Windows icon generated at', icoPath); + } catch (err) { + console.error('ERROR: Windows icon generation failed:', err.message); + process.exit(1); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +function generateMacOSIcon(outputPath) { + if (os.platform() !== 'darwin') { + console.error('ERROR: macOS icon generation requires macOS (uses iconutil).'); + process.exit(1); + } + + const svgPath = path.join(PROJECT_ROOT, 'client', 'public', 'logo.svg'); + if (!fs.existsSync(svgPath)) { + console.error('ERROR: logo.svg not found at', svgPath); + process.exit(1); + } + + console.log('Generating macOS app icon...'); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mds-icon-')); + const iconsetDir = path.join(tmpDir, 'AppIcon.iconset'); + fs.mkdirSync(iconsetDir); + + const sourcePng = path.join(tmpDir, 'icon_1024.png'); + try { + try { + execSync(`rsvg-convert -w 1024 -h 1024 "${svgPath}" -o "${sourcePng}"`, { stdio: 'pipe' }); + } catch { + execSync(`qlmanage -t -s 1024 -o "${tmpDir}" "${svgPath}"`, { stdio: 'pipe' }); + const qlOutput = path.join(tmpDir, 'logo.svg.png'); + if (fs.existsSync(qlOutput)) { + fs.renameSync(qlOutput, sourcePng); + } + } + + if (!fs.existsSync(sourcePng)) { + console.error('ERROR: Could not convert SVG to PNG. Install librsvg (brew install librsvg) for icon support.'); + process.exit(1); + } + + const sizeMap = [ + [16, 'icon_16x16.png'], + [32, 'icon_16x16@2x.png'], + [32, 'icon_32x32.png'], + [64, 'icon_32x32@2x.png'], + [128, 'icon_128x128.png'], + [256, 'icon_128x128@2x.png'], + [256, 'icon_256x256.png'], + [512, 'icon_256x256@2x.png'], + [512, 'icon_512x512.png'], + [1024, 'icon_512x512@2x.png'], + ]; + + for (const [size, name] of sizeMap) { + execSync(`sips -z ${size} ${size} "${sourcePng}" --out "${path.join(iconsetDir, name)}"`, { stdio: 'pipe' }); + } + + const icnsPath = outputPath || path.join(CRATES_DIR, 'cli', 'assets', 'AppIcon.icns'); + const icnsDir = path.dirname(icnsPath); + if (!fs.existsSync(icnsDir)) { + fs.mkdirSync(icnsDir, { recursive: true }); + } + execSync(`iconutil -c icns -o "${icnsPath}" "${iconsetDir}"`, { stdio: 'pipe' }); + console.log('āœ“ macOS icon generated at', icnsPath); + } catch (err) { + console.error('ERROR: Icon generation failed:', err.message); + process.exit(1); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +// Main execution +try { + if (generateWindows) { + generateWindowsIcon(); + } + if (generateMacOS) { + // Generate to a default location that can be copied during build + generateMacOSIcon(); + } + console.log('\nāœ“ Icon generation complete!'); +} catch (error) { + console.error('Icon generation failed:', error.message); + process.exit(1); +} diff --git a/crates/package.json b/crates/package.json index 5e931d0..abc3eeb 100644 --- a/crates/package.json +++ b/crates/package.json @@ -17,13 +17,13 @@ "build-macos": "node build.js macos", "build-windows": "node build.js windows", "build-all": "node build.js all", - "build-macos:legacy": "./build-macos.sh", - "build-windows:legacy": "./build-windows.sh", "test": "cargo make test", "logs": "cargo make logs", "logs:follow": "cargo make logs-follow", "logs:clear": "cargo make logs-clear", "link": "cargo make install", - "unlink": "cargo uninstall md-server" + "unlink": "cargo uninstall md-server", + "gicon:mac": "node generate-icons.js --macos", + "gicon:windows": "node generate-icons.js --windows" } -} +} \ No newline at end of file diff --git a/crates/server/src/handlers/img.rs b/crates/server/src/handlers/img.rs index 2fc8eb4..fea065e 100644 --- a/crates/server/src/handlers/img.rs +++ b/crates/server/src/handlers/img.rs @@ -1,6 +1,6 @@ use axum::{ body::Body, - extract::{Multipart, Path, State}, + extract::{Multipart, Path, Query, State}, http::{HeaderValue, Response, StatusCode, header}, }; @@ -8,16 +8,22 @@ use serde::Deserialize; use crate::{ responses::app::{ApiRes, AppError, AppJson}, - services::img::ImgItem, + services::img::{ImgItem, ImgRefDoc}, state::app::AppState, }; -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DeleteImageRequest { pub file_name: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetImageRefDocsQuery { + pub file_name: String, +} + pub async fn list_images_handler( State(state): State, ) -> Result>, AppError> { @@ -35,6 +41,18 @@ pub async fn delete_image_handler( Ok(ApiRes::success("deleted".to_string())) } +pub async fn get_image_ref_docs_handler( + State(state): State, + Query(params): Query, +) -> Result>, AppError> { + tracing::info!("[ImgHandler] getting image ref docs: {}", params.file_name); + let img_ref_docs = state + .services + .img_service + .get_image_ref_docs(¶ms.file_name)?; + Ok(ApiRes::success(img_ref_docs)) +} + pub async fn get_image_handler( State(state): State, Path(img_path): Path, diff --git a/crates/server/src/handlers/settings.rs b/crates/server/src/handlers/settings.rs index 01697cb..79de75a 100644 --- a/crates/server/src/handlers/settings.rs +++ b/crates/server/src/handlers/settings.rs @@ -22,12 +22,9 @@ pub async fn update_settings_handler( .services .settings_service .update_settings(new_settings)?; - // { - // Ok(updated_settings) => updated_settings, - // Err(e) => return Err(AppError::Unknown(e)), - // }; - state.services.doc_service.sync_settings(&updated_settings); + // Reinitialize git repository if doc_root_path changed + // This is necessary because the git repository needs to be reopened at the new path state.services.git_service.sync_git(&updated_settings); Ok(ApiRes::success(updated_settings)) diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 816dffb..2bcabb4 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -21,6 +21,8 @@ pub struct ServerConfig { pub log_dir: PathBuf, pub log_to_terminal: bool, pub editor_settings_file: PathBuf, + /// Optional directory containing bundled client assets to serve as static files. + pub client_dir: Option, } impl Default for ServerConfig { @@ -31,6 +33,7 @@ impl Default for ServerConfig { log_dir: PathBuf::from("logs"), log_to_terminal: true, editor_settings_file: PathBuf::from("editor-settings.json"), + client_dir: None, } } } @@ -82,7 +85,7 @@ pub fn init_tracing( pub async fn run_server(config: ServerConfig) -> anyhow::Result<()> { let _guard = init_tracing(&config)?; - let app = init_routes(config.editor_settings_file); + let app = init_routes(config.editor_settings_file, config.client_dir); let addr = format!("{}:{}", config.host, config.port); let listener = tokio::net::TcpListener::bind(&addr).await?; diff --git a/crates/server/src/routes/img.rs b/crates/server/src/routes/img.rs index df6e3ed..9d9bebc 100644 --- a/crates/server/src/routes/img.rs +++ b/crates/server/src/routes/img.rs @@ -2,7 +2,8 @@ use axum::{Router, routing}; use crate::{ handlers::img::{ - delete_image_handler, get_image_handler, list_images_handler, upload_image_handler, + delete_image_handler, get_image_handler, get_image_ref_docs_handler, list_images_handler, + upload_image_handler, }, state::app::AppState, }; @@ -14,6 +15,7 @@ pub fn img_routes() -> Router { .route("/list", routing::get(list_images_handler)) .route("/upload", routing::post(upload_image_handler)) .route("/delete", routing::delete(delete_image_handler)) + .route("/ref-docs", routing::get(get_image_ref_docs_handler)) .route("/{*path}", routing::get(get_image_handler)), ) } diff --git a/crates/server/src/routes/root.rs b/crates/server/src/routes/root.rs index a5aa0c9..981f281 100644 --- a/crates/server/src/routes/root.rs +++ b/crates/server/src/routes/root.rs @@ -14,6 +14,7 @@ use tower_http::{ cors::{AllowOrigin, Any, CorsLayer}, normalize_path::{NormalizePath, NormalizePathLayer}, request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer}, + services::{ServeDir, ServeFile}, trace::TraceLayer, }; @@ -30,7 +31,10 @@ use crate::{ const REQUEST_ID_HEADER: &str = "x-request-id"; -pub fn init_routes(editor_settings_file: PathBuf) -> IntoMakeService> { +pub fn init_routes( + editor_settings_file: PathBuf, + client_dir: Option, +) -> IntoMakeService> { let x_request_id = HeaderName::from_static(REQUEST_ID_HEADER); let cors_layer = CorsLayer::new() @@ -82,7 +86,7 @@ pub fn init_routes(editor_settings_file: PathBuf) -> IntoMakeService IntoMakeService>>, - doc_root_path: Arc>, - doc_root_path_depth: Arc>, settings_service: Arc, } const INTERNAL_IGNORE_DIRS: &[&str] = &["_assets"]; impl DocService { - /// Creates a new `DocService` instance and initializes it with current settings. + /// Creates a new `DocService` instance. pub fn new(settings_service: Arc) -> Self { - let service = Self { - ignore_dirs: Arc::new(Mutex::new(Vec::new())), - doc_root_path: Arc::new(Mutex::new(PathBuf::new())), - doc_root_path_depth: Arc::new(Mutex::new(0)), - settings_service, - }; - - // Initialize with current settings - let settings = service.settings_service.get_settings(); - service.sync_settings(&settings); + let service = Self { settings_service }; tracing::info!("[DocService] Docs initialized."); service @@ -66,11 +50,18 @@ impl DocService { } } + let doc_root = self + .settings_service + .settings + .lock() + .unwrap() + .doc_root_path + .clone(); + let ab_doc_path = if !home_root_dir { - self.doc_root_path.lock().unwrap().join(doc_path) + doc_root.join(doc_path) } else { // recover back to folder_doc_path, since path_convertor will add doc_root_path prefix, we need to remove it and add home dir prefix instead - let doc_root = self.doc_root_path.lock().unwrap().clone(); if doc_path.starts_with(&doc_root) { root_dir.join(doc_path.strip_prefix(doc_root).unwrap()) } else { @@ -101,8 +92,15 @@ impl DocService { } let is_file = path.is_file(); - let is_valid_dir = !INTERNAL_IGNORE_DIRS.contains(&name.as_str()) - && !self.ignore_dirs.lock().unwrap().contains(&name); + let ignore_dirs = self + .settings_service + .settings + .lock() + .unwrap() + .ignore_dirs + .clone(); + let is_valid_dir = + !INTERNAL_IGNORE_DIRS.contains(&name.as_str()) && !ignore_dirs.contains(&name); if is_file { if self.is_markdown(&name) { @@ -382,21 +380,6 @@ impl DocService { Ok(()) } - /// Synchronizes service state with settings. - pub fn sync_settings(&self, settings: &crate::services::settings::Settings) { - *self.ignore_dirs.lock().unwrap() = settings.ignore_dirs.clone(); - *self.doc_root_path.lock().unwrap() = settings.doc_root_path.clone(); - *self.doc_root_path_depth.lock().unwrap() = settings.doc_root_path.components().count(); - - if !self.doc_root_path.lock().unwrap().exists() { - tracing::warn!( - "[DocService] Doc root path: {} does not exist, should let user to provide correct path in settings.", - self.doc_root_path.lock().unwrap().display() - ); - return; - } - } - /// Checks if a file name has a markdown extension. fn is_markdown(&self, file_name: &str) -> bool { file_name.ends_with(".md") @@ -433,7 +416,13 @@ impl DocService { } } - let doc_root = self.doc_root_path.lock().unwrap().clone(); + let doc_root = self + .settings_service + .settings + .lock() + .unwrap() + .doc_root_path + .clone(); let mut full_path = doc_root; for part in path_parts { diff --git a/crates/server/src/services/doc/test.rs b/crates/server/src/services/doc/test.rs index 6ef362a..90a10b0 100644 --- a/crates/server/src/services/doc/test.rs +++ b/crates/server/src/services/doc/test.rs @@ -436,7 +436,13 @@ mod tests { #[test] fn test_ignore_directories() { let (service, _temp_dir) = setup_test_service(); - let temp_dir_path = service.doc_root_path.lock().unwrap().clone(); + let temp_dir_path = service + .settings_service + .settings + .lock() + .unwrap() + .doc_root_path + .clone(); // Create ignored directory let ignored_dir = temp_dir_path.join(".git"); @@ -508,7 +514,7 @@ mod tests { } #[test] - fn test_sync_settings() { + fn test_settings_access() { let (service, temp_dir) = setup_test_service(); let new_settings = Settings { doc_root_path: temp_dir.path().join("new-docs"), @@ -518,11 +524,13 @@ mod tests { fs::create_dir_all(&new_settings.doc_root_path).unwrap(); fs::write(new_settings.doc_root_path.join("file.md"), "").unwrap(); - service.sync_settings(&new_settings); + // Update settings directly + *service.settings_service.settings.lock().unwrap() = new_settings.clone(); // Verify settings are updated - let ignore_dirs = service.ignore_dirs.lock().unwrap(); - assert_eq!(ignore_dirs.len(), 1); - assert_eq!(ignore_dirs[0], "custom-ignore"); + let settings = service.settings_service.settings.lock().unwrap(); + assert_eq!(settings.ignore_dirs.len(), 1); + assert_eq!(settings.ignore_dirs[0], "custom-ignore"); + assert_eq!(settings.doc_root_path, new_settings.doc_root_path); } } diff --git a/crates/server/src/services/git.rs b/crates/server/src/services/git.rs index 2d6f111..156ad45 100644 --- a/crates/server/src/services/git.rs +++ b/crates/server/src/services/git.rs @@ -228,13 +228,21 @@ impl GitService { .as_ref() .ok_or_else(|| anyhow::anyhow!("No git repository"))?; + let settings = self.settings_service.get_settings(); let mut index = repo.index()?; // Paths from frontend are relative to doc_root_path (which should be the git repo root) - // So we can add them directly for path in change_paths { let path_obj = std::path::Path::new(&path); - index.add_path(path_obj)?; + let full_path = settings.doc_root_path.join(path_obj); + + // If file exists, add it to index (for new/modified files) + // If file doesn't exist, remove it from index (for deleted files) + if full_path.exists() { + index.add_path(path_obj)?; + } else { + index.remove_path(path_obj)?; + } } index.write()?; @@ -348,6 +356,8 @@ impl GitService { Ok(()) } + /// Reinitializes the git repository when settings change. + /// This reopens the repository at the new doc_root_path if it exists. pub fn sync_git(&self, settings: &Settings) { let doc_root_path = &settings.doc_root_path; diff --git a/crates/server/src/services/img.rs b/crates/server/src/services/img.rs index 8b91718..7c94501 100644 --- a/crates/server/src/services/img.rs +++ b/crates/server/src/services/img.rs @@ -8,7 +8,9 @@ use std::{ use serde::Serialize; use sha2::{Digest, Sha256}; -use crate::services::settings::SettingsService; +use crate::services::{search::SearchService, settings::SettingsService}; + +const ASSETS_DIR: &str = "_assets"; #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -18,15 +20,24 @@ pub struct ImgItem { pub created_time: u64, } -const ASSETS_DIR: &str = "_assets"; +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImgRefDoc { + pub path: Vec, + pub count: u8, +} pub struct ImgService { settings_service: Arc, + search_service: Arc, } impl ImgService { - pub fn new(settings_service: Arc) -> Self { - Self { settings_service } + pub fn new(settings_service: Arc, search_service: Arc) -> Self { + Self { + settings_service, + search_service, + } } /// Reads an image from the doc workspace by relative path. @@ -127,10 +138,6 @@ impl ImgService { let assets_dir = settings.doc_root_path.join(ASSETS_DIR); let file_path = assets_dir.join(file_name); - if !file_path.starts_with(&assets_dir) { - return Err(anyhow::anyhow!("Invalid file name")); - } - if !file_path.exists() { return Err(anyhow::anyhow!("Image not found: {}", file_name)); } @@ -145,6 +152,37 @@ impl ImgService { digest[..8].iter().map(|b| format!("{:02x}", b)).collect() } + /// Get the docs that are using the image link + pub fn get_image_ref_docs(&self, file_name: &str) -> Result, anyhow::Error> { + let settings = self.settings_service.get_settings(); + let assets_dir = settings.doc_root_path.join(ASSETS_DIR); + let file_path = assets_dir.join(file_name); + + if !file_path.exists() { + return Err(anyhow::anyhow!("Image not found: {}", file_name)); + } + + let search_content = format!("](/{}/{})", ASSETS_DIR, &file_name); + tracing::info!( + "[ImgService] searching for image ref docs: {}", + search_content + ); + + let matched_docs = self + .search_service + .search_content(&search_content, true, &[], &[])?; + + let img_ref_docs = matched_docs + .into_iter() + .map(|doc| ImgRefDoc { + path: doc.path, + count: doc.matches.len() as u8, + }) + .collect(); + + Ok(img_ref_docs) + } + /// Finds the correct filename for the given hash: /// - If no file with this hash exists, writes `{hash}.{ext}` and returns it. /// - If a file exists with identical content, returns the existing name (dedup). diff --git a/crates/server/src/services/search.rs b/crates/server/src/services/search.rs index 8f39ca2..fc54e6b 100644 --- a/crates/server/src/services/search.rs +++ b/crates/server/src/services/search.rs @@ -1,6 +1,6 @@ use std::{ path::{Path, PathBuf}, - sync::{Arc, Mutex}, + sync::Arc, }; use grep_regex::RegexMatcherBuilder; @@ -35,41 +35,27 @@ pub struct FileContentMatches { } pub struct SearchService { - ignore_dirs: Arc>>, - doc_root_path: Arc>, settings_service: Arc, } impl SearchService { pub fn new(settings_service: Arc) -> Self { - let service = Self { - ignore_dirs: Arc::new(Mutex::new(Vec::new())), - doc_root_path: Arc::new(Mutex::new(PathBuf::new())), - settings_service, - }; - - let settings = service.settings_service.get_settings(); - service.sync_settings(&settings); + let service = Self { settings_service }; tracing::info!("[SearchService] Search initialized."); service } - pub fn sync_settings(&self, settings: &crate::services::settings::Settings) { - *self.ignore_dirs.lock().unwrap() = settings.ignore_dirs.clone(); - *self.doc_root_path.lock().unwrap() = settings.doc_root_path.clone(); - } - pub fn search_file_names(&self, query: &str) -> Result, anyhow::Error> { - let doc_root = self.doc_root_path.lock().unwrap().clone(); + let doc_root = self.get_doc_root_path(); + let ignore_dirs = self.get_ignore_dirs(); + if !doc_root.exists() { return Err(anyhow::anyhow!( "Doc root path does not exist: {}", doc_root.display() )); } - - let ignore_dirs = self.ignore_dirs.lock().unwrap().clone(); let query_lower = query.to_lowercase(); let mut results = Vec::new(); @@ -119,15 +105,15 @@ impl SearchService { include_patterns: &[String], exclude_patterns: &[String], ) -> Result, anyhow::Error> { - let doc_root = self.doc_root_path.lock().unwrap().clone(); + let doc_root = self.get_doc_root_path(); + let ignore_dirs = self.get_ignore_dirs(); + if !doc_root.exists() { return Err(anyhow::anyhow!( "Doc root path does not exist: {}", doc_root.display() )); } - - let ignore_dirs = self.ignore_dirs.lock().unwrap().clone(); let escaped_query = regex::escape(query); let matcher = RegexMatcherBuilder::new() .case_insensitive(case_insensitive) @@ -234,4 +220,14 @@ impl SearchService { } parts } + + fn get_doc_root_path(&self) -> PathBuf { + let settings = self.settings_service.settings.lock().unwrap(); + settings.doc_root_path.clone() + } + + fn get_ignore_dirs(&self) -> Vec { + let settings = self.settings_service.settings.lock().unwrap(); + settings.ignore_dirs.clone() + } } diff --git a/crates/server/src/services/settings.rs b/crates/server/src/services/settings.rs index 6fcee16..ca9a4d3 100644 --- a/crates/server/src/services/settings.rs +++ b/crates/server/src/services/settings.rs @@ -18,20 +18,48 @@ pub struct Settings { pub ignore_dirs: Vec, } +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct EditorSettings { + pub doc_root_path: PathBuf, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSettings { + pub ignore_dirs: Vec, +} + +impl Default for WorkspaceSettings { + fn default() -> Self { + Self { + ignore_dirs: vec![ + String::from("imgs"), + String::from("node_modules"), + String::from("dist"), + ], + } + } +} + impl Settings { pub fn load_from_file(editor_settings_file: &PathBuf) -> Self { if editor_settings_file.exists() { let file_content = fs::read_to_string(editor_settings_file).unwrap(); - let settings: Settings = serde_json::from_str(&file_content).unwrap(); - settings + + let editor_settings: EditorSettings = serde_json::from_str(&file_content).unwrap(); + let workspace_settings = + Self::load_workspace_settings_from_file(&editor_settings.doc_root_path); + + Settings { + doc_root_path: editor_settings.doc_root_path, + ignore_dirs: workspace_settings.ignore_dirs, + } } else { - let default_settings = Self { + let default_workspace_settings = WorkspaceSettings::default(); + let default_settings = Settings { doc_root_path: PathBuf::from(""), - ignore_dirs: vec![ - String::from("imgs"), - String::from("node_modules"), - String::from("dist"), - ], + ignore_dirs: default_workspace_settings.ignore_dirs.clone(), }; // Ensure parent directory exists @@ -47,9 +75,44 @@ impl Settings { ) .unwrap(); + if default_settings.doc_root_path.exists() { + Self::set_workspace_settings(&default_settings.doc_root_path, &default_workspace_settings); + } + default_settings } } + + pub fn load_workspace_settings_from_file(doc_root_path: &PathBuf) -> WorkspaceSettings { + let workspace_settings_file = doc_root_path.join(".workspace-settings.json"); + if workspace_settings_file.exists() { + let file_content = fs::read_to_string(workspace_settings_file).unwrap(); + let workspace_settings: WorkspaceSettings = serde_json::from_str(&file_content).unwrap(); + workspace_settings + } else { + tracing::info!( + "workspace_settings_file does not exist: {:?}", + workspace_settings_file + ); + + let default_workspace_settings = WorkspaceSettings::default(); + + if doc_root_path.exists() { + Self::set_workspace_settings(doc_root_path, &default_workspace_settings); + } + + default_workspace_settings + } + } + + pub fn set_workspace_settings(doc_root_path: &PathBuf, workspace_settings: &WorkspaceSettings) { + let workspace_settings_file = doc_root_path.join(".workspace-settings.json"); + fs::write( + workspace_settings_file, + serde_json::to_string_pretty(workspace_settings).unwrap(), + ) + .unwrap(); + } } #[derive(Clone)] @@ -60,6 +123,7 @@ pub struct SettingsService { impl SettingsService { pub fn new(editor_settings_file: PathBuf) -> Self { + tracing::info!("editor_settings_file: {:?}", editor_settings_file); let settings = Settings::load_from_file(&editor_settings_file); Self { settings: Arc::new(Mutex::new(settings)), @@ -89,14 +153,22 @@ impl SettingsService { new_settings.doc_root_path = Some(ab_doc_path); self.settings.lock().unwrap().apply(new_settings); + let updated_settings = self.settings.lock().unwrap().clone(); + tracing::info!("settings updated: {:?}", updated_settings); + + let new_editor_settings = EditorSettings { + doc_root_path: updated_settings.doc_root_path.clone(), + }; + let new_worksapce_settings = WorkspaceSettings { + ignore_dirs: updated_settings.ignore_dirs.clone(), + }; + fs::write( &self.editor_settings_file, - serde_json::to_string_pretty(&self.settings.lock().unwrap().clone()).unwrap(), + serde_json::to_string_pretty(&new_editor_settings).unwrap(), ) .unwrap(); - - let updated_settings = self.settings.lock().unwrap().clone(); - tracing::info!("settings updated: {:?}", updated_settings); + Settings::set_workspace_settings(&updated_settings.doc_root_path, &new_worksapce_settings); Ok(updated_settings) } diff --git a/crates/server/src/state/app.rs b/crates/server/src/state/app.rs index fca97ff..ec03059 100644 --- a/crates/server/src/state/app.rs +++ b/crates/server/src/state/app.rs @@ -19,8 +19,11 @@ impl Services { let settings_service = Arc::new(SettingsService::new(editor_settings_file)); let doc_service = Arc::new(DocService::new(settings_service.clone())); let git_service = Arc::new(GitService::new(settings_service.clone())); - let img_service = Arc::new(ImgService::new(settings_service.clone())); let search_service = Arc::new(SearchService::new(settings_service.clone())); + let img_service = Arc::new(ImgService::new( + settings_service.clone(), + search_service.clone(), + )); Self { settings_service, doc_service, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index feb436c..26cf0fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,17 +90,17 @@ importers: specifier: ^11.14.1 version: 11.14.1(@emotion/react@11.14.0)(@types/react@19.1.6)(react@19.1.0) '@milkdown/crepe': - specifier: 7.18.0 - version: 7.18.0(typescript@5.8.3) + specifier: 7.19.0 + version: 7.19.0(typescript@5.8.3) '@milkdown/kit': - specifier: 7.18.0 - version: 7.18.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@5.8.3) + specifier: 7.19.0 + version: 7.19.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@5.8.3) '@milkdown/react': - specifier: 7.18.0 - version: 7.18.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(react-dom@19.1.0)(react@19.1.0)(typescript@5.8.3) + specifier: 7.19.0 + version: 7.19.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(react-dom@19.1.0)(react@19.1.0)(typescript@5.8.3) '@milkdown/utils': - specifier: 7.18.0 - version: 7.18.0 + specifier: 7.19.0 + version: 7.19.0 '@mui/icons-material': specifier: ^7.2.0 version: 7.2.0(@mui/material@7.2.0)(@types/react@19.1.6)(react@19.1.0) @@ -1716,8 +1716,8 @@ packages: langium: 3.3.1 dev: false - /@milkdown/components@7.18.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@5.8.3): - resolution: {integrity: sha512-Zu/GMqy1byyxul/+/RWcpe02b7luhtW1SfTYNFZnaWPvIap5M9vG7pFeQNRqJe5cbfKI+bvW8Ubyb5BG2kb9Ug==} + /@milkdown/components@7.19.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@5.8.3): + resolution: {integrity: sha512-l/xasav/CPVXQZWs5oiFtnWw2zMk4Bq1EmKFElzsaKJCCW7ZBofasoGoQY5h0j+CDM8nAe8WLTq87WWWb9Ut6A==} peerDependencies: '@codemirror/language': ^6 '@codemirror/state': ^6 @@ -1727,15 +1727,15 @@ packages: '@codemirror/state': 6.5.2 '@codemirror/view': 6.37.1 '@floating-ui/dom': 1.7.1 - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/exception': 7.18.0 - '@milkdown/plugin-tooltip': 7.18.0 - '@milkdown/preset-commonmark': 7.18.0 - '@milkdown/preset-gfm': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/transformer': 7.18.0 - '@milkdown/utils': 7.18.0 + '@milkdown/core': 7.19.0 + '@milkdown/ctx': 7.19.0 + '@milkdown/exception': 7.19.0 + '@milkdown/plugin-tooltip': 7.19.0 + '@milkdown/preset-commonmark': 7.19.0 + '@milkdown/preset-gfm': 7.19.0 + '@milkdown/prose': 7.19.0 + '@milkdown/transformer': 7.19.0 + '@milkdown/utils': 7.19.0 '@types/lodash-es': 4.17.12 clsx: 2.1.1 dompurify: 3.2.6 @@ -1748,13 +1748,13 @@ packages: - typescript dev: false - /@milkdown/core@7.18.0: - resolution: {integrity: sha512-BUVR/72XwrtM3qHTTtXtmCtGfuaAexvSxosYIXw7d6ElbLiLIe3bOXjGwwgLHW3xsq23VKmYMsFqWLUFt6uGDQ==} + /@milkdown/core@7.19.0: + resolution: {integrity: sha512-x5vxnVCxxKSGCa1+J7I4RzEDl4KkvsXJF6xm1zCtvj0BCsbCFGiUVx2AtLcFkkvWZ5530CuOouDJ9FC27yoCoA==} dependencies: - '@milkdown/ctx': 7.18.0 - '@milkdown/exception': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/transformer': 7.18.0 + '@milkdown/ctx': 7.19.0 + '@milkdown/exception': 7.19.0 + '@milkdown/prose': 7.19.0 + '@milkdown/transformer': 7.19.0 remark-parse: 11.0.0 remark-stringify: 11.0.0 unified: 11.0.5 @@ -1762,8 +1762,8 @@ packages: - supports-color dev: false - /@milkdown/crepe@7.18.0(typescript@5.8.3): - resolution: {integrity: sha512-GcHW6Use0MCRvFg6RQVN5EaeyMlxFxDEGbGwqApnBblxZi5PV9nlAAn0AfOhYvFHSDkQ3rQa5fuHQ0Bd0KobQQ==} + /@milkdown/crepe@7.19.0(typescript@5.8.3): + resolution: {integrity: sha512-3vY/5l8xc3LS1bh/bzzfuVhiFPP1gBYCSTp6iu6TXUgMHJvu8Tp2yhyBrjIWhiogA281cuA50i9gG47wOjWAIQ==} dependencies: '@codemirror/commands': 6.8.1 '@codemirror/language': 6.11.3 @@ -1771,7 +1771,7 @@ packages: '@codemirror/state': 6.5.2 '@codemirror/theme-one-dark': 6.1.2 '@codemirror/view': 6.37.1 - '@milkdown/kit': 7.18.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@5.8.3) + '@milkdown/kit': 7.19.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@5.8.3) '@types/lodash-es': 4.17.12 clsx: 2.1.1 codemirror: 6.0.1 @@ -1789,37 +1789,37 @@ packages: - typescript dev: false - /@milkdown/ctx@7.18.0: - resolution: {integrity: sha512-F+t8U/akpY7Vw+KD+z32Itr6lrVLAGTVO79DN436BnFK/J9kiPzTRfTet6fMOj3NlwO/24lUluiPZd7qbCmn8A==} + /@milkdown/ctx@7.19.0: + resolution: {integrity: sha512-tdG9jm6yk6PRSvFZW5rRSqOGrKdcNdbXJwfGiEGr538pgKYhJ/yKPF3HmfupkhGyxabRP/PydQa4q/N/OOs03g==} dependencies: - '@milkdown/exception': 7.18.0 + '@milkdown/exception': 7.19.0 dev: false - /@milkdown/exception@7.18.0: - resolution: {integrity: sha512-sAyi4IqdChh4+lpgucmgDZNGjYuIRvJimZeMj0SdfdeHDABan5Nco3X+5yOGaBq1z9QOJG90+vEcEvUASHBmFw==} + /@milkdown/exception@7.19.0: + resolution: {integrity: sha512-ykgjxqrOueTCjmDGr0aidulZa1mC6bg4f8eDyMiT0wd4vB+3iYmQxY8NxdKwUqlz4UM5KBnbyFlGlgQsQDL+ew==} dev: false - /@milkdown/kit@7.18.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@5.8.3): - resolution: {integrity: sha512-6C8c/bU+3Md/rlZFTqMmdVen2xSC80LYBOZ/G4+W39gsV7x/ux/HRdd8xk75a4IrHKgq6EJpGJ1yH8BvT7P+1A==} + /@milkdown/kit@7.19.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@5.8.3): + resolution: {integrity: sha512-q+FF2dLMpw056mwowg1de+vcDl2gLyNfBmOCJ1WjJSqw4evlcYcKjYgKZWViE/WLOjryQDQhDlg0g58/uUFiyQ==} dependencies: - '@milkdown/components': 7.18.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@5.8.3) - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/plugin-block': 7.18.0 - '@milkdown/plugin-clipboard': 7.18.0 - '@milkdown/plugin-cursor': 7.18.0 - '@milkdown/plugin-history': 7.18.0 - '@milkdown/plugin-indent': 7.18.0 - '@milkdown/plugin-listener': 7.18.0 - '@milkdown/plugin-slash': 7.18.0 - '@milkdown/plugin-tooltip': 7.18.0 - '@milkdown/plugin-trailing': 7.18.0 - '@milkdown/plugin-upload': 7.18.0 - '@milkdown/preset-commonmark': 7.18.0 - '@milkdown/preset-gfm': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/transformer': 7.18.0 - '@milkdown/utils': 7.18.0 + '@milkdown/components': 7.19.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@5.8.3) + '@milkdown/core': 7.19.0 + '@milkdown/ctx': 7.19.0 + '@milkdown/plugin-block': 7.19.0 + '@milkdown/plugin-clipboard': 7.19.0 + '@milkdown/plugin-cursor': 7.19.0 + '@milkdown/plugin-history': 7.19.0 + '@milkdown/plugin-indent': 7.19.0 + '@milkdown/plugin-listener': 7.19.0 + '@milkdown/plugin-slash': 7.19.0 + '@milkdown/plugin-tooltip': 7.19.0 + '@milkdown/plugin-trailing': 7.19.0 + '@milkdown/plugin-upload': 7.19.0 + '@milkdown/preset-commonmark': 7.19.0 + '@milkdown/preset-gfm': 7.19.0 + '@milkdown/prose': 7.19.0 + '@milkdown/transformer': 7.19.0 + '@milkdown/utils': 7.19.0 transitivePeerDependencies: - '@codemirror/language' - '@codemirror/state' @@ -1828,132 +1828,132 @@ packages: - typescript dev: false - /@milkdown/plugin-block@7.18.0: - resolution: {integrity: sha512-+x00o7Vh5nQesw4j6QwtwCThdjSiH/jUvAzrTpwr8xvRmQnmztdfdJhPHxp48pK/sIEct3660HWuwDpdeAlmRw==} + /@milkdown/plugin-block@7.19.0: + resolution: {integrity: sha512-VCOscCUXOlkOO/i3PYUHHJ9nAk3rjBGlEB6Rs3Ge7hJbuv2Hb/5mTiWI2KRARu1deGaEaYUjsH8NX+BOH3ZMew==} dependencies: '@floating-ui/dom': 1.7.1 - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/utils': 7.18.0 + '@milkdown/core': 7.19.0 + '@milkdown/ctx': 7.19.0 + '@milkdown/prose': 7.19.0 + '@milkdown/utils': 7.19.0 '@types/lodash-es': 4.17.12 lodash-es: 4.17.21 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-clipboard@7.18.0: - resolution: {integrity: sha512-Gnp+GqkoLS1pKG9S2QfdvZQjfoJosQek5Yv5zOIj5X388yfVlguKNtCwnDCJKVEVws9e8PnhfPBmzr06713dZw==} + /@milkdown/plugin-clipboard@7.19.0: + resolution: {integrity: sha512-V29/XE3M/ffvc5Owdm9tdUaD/GZ5AHMgbpBkd6+2/PH09MfGA9CPTpAjhqxxkWEi3CoT0HapdoceKuzd5bD7Nw==} dependencies: - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/utils': 7.18.0 + '@milkdown/core': 7.19.0 + '@milkdown/ctx': 7.19.0 + '@milkdown/prose': 7.19.0 + '@milkdown/utils': 7.19.0 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-cursor@7.18.0: - resolution: {integrity: sha512-SsvFEeFMv1jrzVBnuAMyAwZzhjwCk4wmGjJEug41Ic+CT0YMUtVPJn5QVn7fjixR13kzkfaNDUPZ+sGNqIR2xw==} + /@milkdown/plugin-cursor@7.19.0: + resolution: {integrity: sha512-NGAuTxSbOdy3nHQ8bTk9I6Vi1eX14xpcN7QU65aIJM01RnGhTBx5cF6f82n0IWTXMbN+MVOuQfqywfdRx1ukLw==} dependencies: - '@milkdown/ctx': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/utils': 7.18.0 + '@milkdown/ctx': 7.19.0 + '@milkdown/prose': 7.19.0 + '@milkdown/utils': 7.19.0 prosemirror-drop-indicator: 0.1.0 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-history@7.18.0: - resolution: {integrity: sha512-hWM3rpad/THy267dXgEWRu9Arf+3j2KE8UN3jhqsUvVLZZ2ZetaPc2imHowJaLR8PwCb649+1RxL+IKrXizNKQ==} + /@milkdown/plugin-history@7.19.0: + resolution: {integrity: sha512-bC5bN0Ep5AC3hkiPS6LJTkorBoe5S7meJNzO0WqcIpkwckHD3M59Z7uz6dSUZdxVcL5IcFegDVsEYkiDq1Jcrw==} dependencies: - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/utils': 7.18.0 + '@milkdown/core': 7.19.0 + '@milkdown/ctx': 7.19.0 + '@milkdown/prose': 7.19.0 + '@milkdown/utils': 7.19.0 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-indent@7.18.0: - resolution: {integrity: sha512-LAVMSsy6lWvy/QjvSazojUeW6v1lLFj5Fjv3YvqDNtP6/RSOIhHJs75aXbv92Kx43aRJnkh7EVy9Wu4OxSC70Q==} + /@milkdown/plugin-indent@7.19.0: + resolution: {integrity: sha512-P9rJK9OHCmqry37pAFcknY3VVvAEpUt7RflfdjFXSb3aGyJb+TDaQBiTHgxzTTXmvmAaPGLm2uuGtx34ThrA1A==} dependencies: - '@milkdown/ctx': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/utils': 7.18.0 + '@milkdown/ctx': 7.19.0 + '@milkdown/prose': 7.19.0 + '@milkdown/utils': 7.19.0 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-listener@7.18.0: - resolution: {integrity: sha512-F2iPKdWYGJX5kMnmIeZeybQ5gZUwT/smNBbt/itPBn5cD4YRF1qmY/MxDs0+nvoN2NSxtEx5pHOtd5/E4mCf2A==} + /@milkdown/plugin-listener@7.19.0: + resolution: {integrity: sha512-shEVqcC2SKH0jaB74ReztNxG5hXjby26S1lN+evHKtsy2cB2vbWH2eHqSZkl8EGbLZJrqDsHwDKxTNTwB/oMSg==} dependencies: - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/prose': 7.18.0 + '@milkdown/core': 7.19.0 + '@milkdown/ctx': 7.19.0 + '@milkdown/prose': 7.19.0 '@types/lodash-es': 4.17.12 lodash-es: 4.17.21 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-slash@7.18.0: - resolution: {integrity: sha512-jBcaLswX1yKG97s0V1qFqk/0aR+LpWnTCHIrryNVRIRFYm7B6tITekkqwALlV2bqE1eykeN2j8yEyRQ63Wv05Q==} + /@milkdown/plugin-slash@7.19.0: + resolution: {integrity: sha512-mdcqxOC9voMHKBScCGZjtSU6xTd6/Z6Oc7XsTQ5Yc0XqRgqcYAL3ti/1dtP2BMy8zXu/pHegatOzGWO9DlrcUQ==} dependencies: '@floating-ui/dom': 1.7.1 - '@milkdown/ctx': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/utils': 7.18.0 + '@milkdown/ctx': 7.19.0 + '@milkdown/prose': 7.19.0 + '@milkdown/utils': 7.19.0 '@types/lodash-es': 4.17.12 lodash-es: 4.17.21 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-tooltip@7.18.0: - resolution: {integrity: sha512-Z8WYSEFANhHPS2A8uMIcKGJ3vt0KKCJ80hffuJffudJT9FSIXieh1f8OKcKQuhcRHxRCRUApMcOOjOptiVaHvQ==} + /@milkdown/plugin-tooltip@7.19.0: + resolution: {integrity: sha512-2HBiMgQ3aY/jdbxRRAbUkglk+PACCNGL7AERssRhr3G3Ph+eNwJYpK6VN7eHsUyDbFSP0koS7npr6U2kZWThFg==} dependencies: '@floating-ui/dom': 1.7.1 - '@milkdown/ctx': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/utils': 7.18.0 + '@milkdown/ctx': 7.19.0 + '@milkdown/prose': 7.19.0 + '@milkdown/utils': 7.19.0 '@types/lodash-es': 4.17.12 lodash-es: 4.17.21 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-trailing@7.18.0: - resolution: {integrity: sha512-AusCWoZSRfgsStdlmg+4sYZ08HLDDiHhesDCqiLCdo1bklNhzK/9q6gxdL1HP5xTn5a4xV9hUrI7E7M0JaKdug==} + /@milkdown/plugin-trailing@7.19.0: + resolution: {integrity: sha512-VbAqrvZq4S0kFVipuNmM+Qg8PvvT6SUXhRCMWzM0tX1I7H6Lie+oT/G5bpNPSKh7BNi7pbCllLGi0/L88vSzSw==} dependencies: - '@milkdown/ctx': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/utils': 7.18.0 + '@milkdown/ctx': 7.19.0 + '@milkdown/prose': 7.19.0 + '@milkdown/utils': 7.19.0 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-upload@7.18.0: - resolution: {integrity: sha512-fsWwd6g6FX35Wg12KVE1Yu3wU8vM5hA567DufeHcik9LckdLJcZKf35JMJDUOAOkEdU3V91BKO47KUhBPFt1jA==} + /@milkdown/plugin-upload@7.19.0: + resolution: {integrity: sha512-dAcxLf8TvCljgrRUa65lV3MYA5HAmCOjVHS0CzKCfC568T4eg3K2kSbr+EFEYCSR7vCbLjm/o9F4kI4qaWmAAw==} dependencies: - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/exception': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/utils': 7.18.0 + '@milkdown/core': 7.19.0 + '@milkdown/ctx': 7.19.0 + '@milkdown/exception': 7.19.0 + '@milkdown/prose': 7.19.0 + '@milkdown/utils': 7.19.0 transitivePeerDependencies: - supports-color dev: false - /@milkdown/preset-commonmark@7.18.0: - resolution: {integrity: sha512-L/F9vmhQKOjKJZTEEsKjDu/2KkMTDxBVQISk4w+j8KFWx9OpHBwqWqyHiDLTREbT7pJqLfyB96eXvfuMG4za5g==} + /@milkdown/preset-commonmark@7.19.0: + resolution: {integrity: sha512-8rPEd4S3ny5wuFJvnZdieedKxFeW3KU5Rz54LYhA/nYPG+tE9q5lqDs0ZzHNoJdXMiLWbNf/dd0QokHVNlbQLQ==} dependencies: - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/exception': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/transformer': 7.18.0 - '@milkdown/utils': 7.18.0 + '@milkdown/core': 7.19.0 + '@milkdown/ctx': 7.19.0 + '@milkdown/exception': 7.19.0 + '@milkdown/prose': 7.19.0 + '@milkdown/transformer': 7.19.0 + '@milkdown/utils': 7.19.0 remark-inline-links: 7.0.0 unist-util-visit: 5.0.0 unist-util-visit-parents: 6.0.1 @@ -1961,26 +1961,26 @@ packages: - supports-color dev: false - /@milkdown/preset-gfm@7.18.0: - resolution: {integrity: sha512-NLfkd7HOaaMCMImXmBh8TX8KNkgKecM7YRHFEwb5D/SMLyBLyZs7lDfLEKPU9N52+vzgwMz8ceUSlCElmneTJg==} + /@milkdown/preset-gfm@7.19.0: + resolution: {integrity: sha512-wW5ShJUhIaWNnbtv4IjV+xh9TvVId+Lm8CAurUs2E1nBX2N5wHTzzl2/9WOTt/g4u49e64rJewkwZJri8MPy7g==} dependencies: - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/exception': 7.18.0 - '@milkdown/preset-commonmark': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/transformer': 7.18.0 - '@milkdown/utils': 7.18.0 + '@milkdown/core': 7.19.0 + '@milkdown/ctx': 7.19.0 + '@milkdown/exception': 7.19.0 + '@milkdown/preset-commonmark': 7.19.0 + '@milkdown/prose': 7.19.0 + '@milkdown/transformer': 7.19.0 + '@milkdown/utils': 7.19.0 prosemirror-safari-ime-span: 1.0.2 remark-gfm: 4.0.1 transitivePeerDependencies: - supports-color dev: false - /@milkdown/prose@7.18.0: - resolution: {integrity: sha512-bRDfgVM6uKRaejvju/FWdQMryQc4kSSso+fnABUbvbCKitXnsgRPvclsddbt3J92anQwLRDWr/qotx1NcyDM1Q==} + /@milkdown/prose@7.19.0: + resolution: {integrity: sha512-T/uYqSr4YT4uZtu4nBxTWyvZhVs2Lzh9qpcYH81PVwtZUT3b57+e+39s1D7UKAwFGi0qB7qZu/53l6pcw8radg==} dependencies: - '@milkdown/exception': 7.18.0 + '@milkdown/exception': 7.19.0 prosemirror-changeset: 2.3.1 prosemirror-commands: 1.7.1 prosemirror-dropcursor: 1.8.2 @@ -1996,14 +1996,14 @@ packages: prosemirror-view: 1.41.3 dev: false - /@milkdown/react@7.18.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(react-dom@19.1.0)(react@19.1.0)(typescript@5.8.3): - resolution: {integrity: sha512-hk7CN6YqhazUBOdY0Iyh3RjvRyjsl2vBsJyf54ua38hxmaAD13KbTnEWZs30OnryoP6cv9z74bHPMIc2UnSVIQ==} + /@milkdown/react@7.19.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(react-dom@19.1.0)(react@19.1.0)(typescript@5.8.3): + resolution: {integrity: sha512-rtvnM2tC8A0IAzkG2u7XYPNtN08Yek+z9AHUbf4SRyVbXsOCTRSZxA7FEGul1iLl6TUz4oU8tQnMHPH4iWXBtA==} peerDependencies: react: '*' react-dom: '*' dependencies: - '@milkdown/crepe': 7.18.0(typescript@5.8.3) - '@milkdown/kit': 7.18.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@5.8.3) + '@milkdown/crepe': 7.19.0(typescript@5.8.3) + '@milkdown/kit': 7.19.0(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@5.8.3) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) transitivePeerDependencies: @@ -2017,25 +2017,25 @@ packages: - typescript dev: false - /@milkdown/transformer@7.18.0: - resolution: {integrity: sha512-AzTgqDktQw9nzgrpICjYNxScYwwnxmALPSyZ39Y0wNZJafi8QMVqLv4w2bhyYkxITXolPHdLAAsZXPKuMjrmNA==} + /@milkdown/transformer@7.19.0: + resolution: {integrity: sha512-Ui1vwbyTd1nAaieTylI8ibNbXSAxygkiFjkwOPGO5w0Eu7leH+0hVrbeGUCSzYdJfjGsN537CYu/8kvLIR+lQg==} dependencies: - '@milkdown/exception': 7.18.0 - '@milkdown/prose': 7.18.0 + '@milkdown/exception': 7.19.0 + '@milkdown/prose': 7.19.0 remark: 15.0.1 unified: 11.0.5 transitivePeerDependencies: - supports-color dev: false - /@milkdown/utils@7.18.0: - resolution: {integrity: sha512-+o/1sky+QwbS0Y92HthTupMFziJKhZUgF7IBS55Ft4Wjt63kX8PHaLC9KtewNawpzyM/CjPJ9ySCIa+C/06Bsg==} + /@milkdown/utils@7.19.0: + resolution: {integrity: sha512-aIu8j7TypVn+4ZWgrIUjpljIulAVwNERWNZgkfYLQLOv+BbF1gIbpoB7t3w0RD2EeENrEu0P3J0Sl5LDMbyDRQ==} dependencies: - '@milkdown/core': 7.18.0 - '@milkdown/ctx': 7.18.0 - '@milkdown/exception': 7.18.0 - '@milkdown/prose': 7.18.0 - '@milkdown/transformer': 7.18.0 + '@milkdown/core': 7.19.0 + '@milkdown/ctx': 7.19.0 + '@milkdown/exception': 7.19.0 + '@milkdown/prose': 7.19.0 + '@milkdown/transformer': 7.19.0 nanoid: 5.1.5 transitivePeerDependencies: - supports-color @@ -5749,7 +5749,7 @@ packages: resolution: {integrity: sha512-2owtnbsn6YcQdSletuC+RisMj7eAMn69Bpy0GZj3uUSabh6UmBPumN9Y4s8c76EqgORmxAyQy+I4+j0goHekOg==} dependencies: '@ocavue/utils': 0.7.1 - prosemirror-model: 1.25.3 + prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 prosemirror-view: 1.41.3 dev: false @@ -5794,12 +5794,6 @@ packages: w3c-keyname: 2.2.8 dev: false - /prosemirror-model@1.25.3: - resolution: {integrity: sha512-dY2HdaNXlARknJbrManZ1WyUtos+AP97AmvqdOQtWtrrC5g4mohVX5DTi9rXNFSk09eczLq9GuNTtq3EfMeMGA==} - dependencies: - orderedmap: 2.1.1 - dev: false - /prosemirror-model@1.25.4: resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} dependencies: diff --git a/scripts/bump-version.ts b/scripts/bump-version.ts index e6e5252..0bb3ba2 100644 --- a/scripts/bump-version.ts +++ b/scripts/bump-version.ts @@ -392,7 +392,7 @@ function handlePreRelease(newVersion: string, dryRun: boolean): void { // Stage and commit exec('git add package.json crates/Cargo.toml crates/package.json client/package.json'); - exec(`git commit -m "chore: bump version to ${newVersion}"`); + exec(`git commit -m "chore: šŸŽ‰ release version ${newVersion}"`); // Create tag exec(`git tag -a ${tagName} -m "Release ${newVersion}"`);