From 4d7f36d072b9a146ed981fc538d74d91291a31d1 Mon Sep 17 00:00:00 2001 From: s-elo Date: Tue, 3 Mar 2026 12:53:48 +0800 Subject: [PATCH 01/20] feat: support local client and refine bundled App --- .github/workflows/build.yml | 16 +- .gitignore | 9 +- client/rsbuild.config.ts | 5 +- .../components/Editor/configs/uploadConfig.ts | 8 +- client/src/redux-api/docs.ts | 4 +- crates/BUILD.md | 6 +- crates/Cargo.lock | 175 +++++++++ crates/Cargo.toml | 2 +- crates/build-macos.sh | 134 +++++-- crates/build-windows.sh | 161 +++++--- crates/build.js | 352 ++++++++++++++---- crates/cli/Cargo.toml | 4 + crates/cli/build.rs | 19 + crates/cli/src/commands/install.rs | 300 +++------------ crates/cli/src/commands/mod.rs | 4 +- crates/cli/src/commands/start.rs | 40 +- crates/cli/src/commands/status.rs | 21 -- crates/cli/src/commands/uninstall.rs | 199 ---------- crates/cli/src/main.rs | 101 +++-- crates/cli/src/utils/mod.rs | 215 ----------- crates/cli/src/utils/system_commands.rs | 87 +---- crates/server/src/lib.rs | 5 +- crates/server/src/routes/root.rs | 14 +- scripts/bump-version.ts | 2 +- 24 files changed, 909 insertions(+), 974 deletions(-) create mode 100644 crates/cli/build.rs delete mode 100644 crates/cli/src/commands/uninstall.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f6e229a..17d62ab 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: @@ -186,13 +186,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 +205,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..e41a0f8 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ yarn-debug.log* yarn-error.log* dist +dist-local config.json @@ -86,5 +87,9 @@ 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 + +# Generated build assets +crates/cli/assets/icon.ico 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/configs/uploadConfig.ts b/client/src/components/Editor/configs/uploadConfig.ts index 63c557e..29d38cf 100644 --- a/client/src/components/Editor/configs/uploadConfig.ts +++ b/client/src/components/Editor/configs/uploadConfig.ts @@ -6,9 +6,13 @@ import type { Node } from '@milkdown/kit/prose/model'; import { SERVER_PORT } from '@/constants'; +function getApiBase() { + return SERVER_PORT ? `http://127.0.0.1:${SERVER_PORT}/api` : '/api'; +} + export function getImageUrl(url: string) { if (url.startsWith('/')) { - return `http://127.0.0.1:${SERVER_PORT}/api/imgs${url}`; + return `${getApiBase()}/imgs${url}`; } return url; } @@ -16,7 +20,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(`${getApiBase()}/imgs/upload`, { method: 'POST', body: formData, }); diff --git a/client/src/redux-api/docs.ts b/client/src/redux-api/docs.ts index 346192f..f39ab83 100644 --- a/client/src/redux-api/docs.ts +++ b/client/src/redux-api/docs.ts @@ -13,7 +13,9 @@ import { } from './docsApiType'; import { transformResponse, transformErrorResponse } from './interceptor'; -const baseQuery = fetchBaseQuery({ baseUrl: `http://127.0.0.1:${__SERVER_PORT__}/api` }); +const baseQuery = fetchBaseQuery({ + baseUrl: __SERVER_PORT__ ? `http://127.0.0.1:${__SERVER_PORT__}/api` : '/api', +}); export const docsApi = createApi({ reducerPath: '/docs', diff --git a/crates/BUILD.md b/crates/BUILD.md index 599dc39..a48af5f 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 @@ -103,8 +103,8 @@ 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 diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 2ae6cef..5e4dd5b 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,7 @@ dependencies = [ "daemonize", "dirs", "nix", + "open", "server", "sysinfo", "tokio", @@ -934,6 +988,7 @@ dependencies = [ "tracing-subscriber", "windows-service", "winreg", + "winresource", ] [[package]] @@ -957,6 +1012,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 +1098,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 +1178,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 +1410,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 +1702,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 +1778,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 +1889,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 +2424,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 +2440,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 index 9d69ae3..bb483d9 100755 --- a/crates/build-macos.sh +++ b/crates/build-macos.sh @@ -1,28 +1,58 @@ #!/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) +# Build script for creating macOS .app bundle with bundled client +# Usage: ./crates/build-macos.sh [--intel|--arm|--universal] [--skip-client] +# --intel Build for Intel Macs only (x86_64) +# --arm Build for Apple Silicon only (aarch64) +# --universal Build universal binary (default, works on both) +# --skip-client Skip building the client (use pre-built client assets) 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" +CLIENT_DIST_DIR="${PROJECT_ROOT}/dist-local" +APP_NAME="Markdown-Editor" BINARY_NAME="mds" BUNDLE_ID="com.markdown-editor.mds" # Parse arguments -BUILD_TYPE="${1:-universal}" +BUILD_TYPE="universal" +SKIP_CLIENT=false +for arg in "$@"; do + case "$arg" in + --intel|--arm|--universal) BUILD_TYPE="${arg#--}" ;; + --skip-client) SKIP_CLIENT=true ;; + esac +done # 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}..." +echo "Building Markdown-Editor v${VERSION}..." + +# Step 1: Build client for local bundle +if [ "$SKIP_CLIENT" = false ]; then + echo "" + echo "Building client for local bundle..." + rm -rf "$CLIENT_DIST_DIR" + cd "$PROJECT_ROOT" + SERVER_PORT= CLIENT_DIST_PATH="$CLIENT_DIST_DIR" pnpm --filter client build + if [ ! -f "$CLIENT_DIST_DIR/index.html" ]; then + echo "ERROR: Client build did not produce index.html" + exit 1 + fi + echo "✓ Client built to $CLIENT_DIST_DIR" +else + if [ ! -d "$CLIENT_DIST_DIR" ]; then + echo "ERROR: --skip-client specified but no pre-built client at $CLIENT_DIST_DIR" + exit 1 + fi + echo "Using pre-built client from $CLIENT_DIST_DIR" +fi +# Step 2: Build server binary cd "$CRATES_DIR" # Detect host architecture @@ -40,9 +70,7 @@ build_for_target() { 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 @@ -59,15 +87,15 @@ build_for_target() { # Build based on selected type case "$BUILD_TYPE" in - --intel) + intel) build_for_target "x86_64-apple-darwin" "Intel (x86_64)" BINARY_PATH="target/x86_64-apple-darwin/release/${BINARY_NAME}" ;; - --arm) + arm) build_for_target "aarch64-apple-darwin" "Apple Silicon (aarch64)" BINARY_PATH="target/aarch64-apple-darwin/release/${BINARY_NAME}" ;; - --universal|*) + 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)" @@ -82,14 +110,51 @@ case "$BUILD_TYPE" in ;; esac +# Step 3: Generate app icon from logo SVG +echo "Generating app icon..." +ICON_SVG="${PROJECT_ROOT}/client/public/logo.svg" +ICON_ICNS="" +if [ -f "$ICON_SVG" ]; then + TEMP_DIR=$(mktemp -d) + ICONSET_DIR="${TEMP_DIR}/AppIcon.iconset" + mkdir -p "$ICONSET_DIR" + + SOURCE_PNG="${TEMP_DIR}/icon_1024.png" + if command -v rsvg-convert >/dev/null 2>&1; then + rsvg-convert -w 1024 -h 1024 "$ICON_SVG" -o "$SOURCE_PNG" + else + qlmanage -t -s 1024 -o "$TEMP_DIR" "$ICON_SVG" 2>/dev/null + mv "${TEMP_DIR}/logo.svg.png" "$SOURCE_PNG" 2>/dev/null || true + fi + + if [ -f "$SOURCE_PNG" ]; then + sips -z 16 16 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_16x16.png" >/dev/null 2>&1 + sips -z 32 32 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_16x16@2x.png" >/dev/null 2>&1 + sips -z 32 32 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_32x32.png" >/dev/null 2>&1 + sips -z 64 64 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_32x32@2x.png" >/dev/null 2>&1 + sips -z 128 128 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_128x128.png" >/dev/null 2>&1 + sips -z 256 256 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_128x128@2x.png" >/dev/null 2>&1 + sips -z 256 256 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_256x256.png" >/dev/null 2>&1 + sips -z 512 512 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_256x256@2x.png" >/dev/null 2>&1 + sips -z 512 512 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_512x512.png" >/dev/null 2>&1 + sips -z 1024 1024 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_512x512@2x.png" >/dev/null 2>&1 + + ICON_ICNS="${TEMP_DIR}/AppIcon.icns" + iconutil -c icns -o "$ICON_ICNS" "$ICONSET_DIR" + echo "✓ App icon generated" + else + echo "Warning: Could not convert SVG to PNG. Install librsvg (brew install librsvg) for icon support." + fi +fi + +# Step 4: Create app bundle 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" +rm -f "${DIST_DIR}/markdown-editor-macos.zip" # Create app bundle structure mkdir -p "${DIST_DIR}/${APP_NAME}.app/Contents/MacOS" @@ -98,6 +163,17 @@ mkdir -p "${DIST_DIR}/${APP_NAME}.app/Contents/Resources" # Copy binary cp "${BINARY_PATH}" "${DIST_DIR}/${APP_NAME}.app/Contents/MacOS/" +# Copy app icon +if [ -n "$ICON_ICNS" ] && [ -f "$ICON_ICNS" ]; then + cp "$ICON_ICNS" "${DIST_DIR}/${APP_NAME}.app/Contents/Resources/AppIcon.icns" +fi + +# Copy client assets into Resources/client/ +if [ -d "$CLIENT_DIST_DIR" ]; then + echo "Bundling client assets..." + cp -r "$CLIENT_DIST_DIR" "${DIST_DIR}/${APP_NAME}.app/Contents/Resources/client" +fi + # Create Info.plist cat > "${DIST_DIR}/${APP_NAME}.app/Contents/Info.plist" << EOF @@ -111,7 +187,9 @@ cat > "${DIST_DIR}/${APP_NAME}.app/Contents/Info.plist" << EOF CFBundleName ${APP_NAME} CFBundleDisplayName - Markdown Editor Server + Markdown Editor + CFBundleIconFile + AppIcon CFBundleVersion ${VERSION} CFBundleShortVersionString @@ -131,25 +209,19 @@ EOF # Create PkgInfo echo -n "APPL????" > "${DIST_DIR}/${APP_NAME}.app/Contents/PkgInfo" +# Clean up icon temp files +if [ -n "$TEMP_DIR" ] && [ -d "$TEMP_DIR" ]; then + rm -rf "$TEMP_DIR" +fi + echo "Creating zip archive..." cd "$DIST_DIR" -zip -r "mds-macos.zip" "${APP_NAME}.app" +zip -r "markdown-editor-macos.zip" "${APP_NAME}.app" echo "" echo "✓ Build complete!" echo " Version: ${VERSION}" -echo " Build type: ${BUILD_TYPE#--}" +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)" - +echo " Zip file: ${DIST_DIR}/markdown-editor-macos.zip" +echo " Client: bundled in Resources/client/" diff --git a/crates/build-windows.sh b/crates/build-windows.sh index 8e9be44..243d3e9 100755 --- a/crates/build-windows.sh +++ b/crates/build-windows.sh @@ -1,62 +1,119 @@ #!/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) +# Build script for creating Windows executable with bundled client +# Usage: ./crates/build-windows.sh [--gnu|--msvc] [--skip-client] +# --gnu Build using GNU toolchain (cross-compile from macOS/Linux, default) +# --msvc Build using MSVC toolchain (requires Windows or special setup) +# --skip-client Skip building the client (use pre-built client assets) SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" CRATES_DIR="${PROJECT_ROOT}/crates" DIST_DIR="${PROJECT_ROOT}/dist" +CLIENT_DIST_DIR="${PROJECT_ROOT}/dist-local" BINARY_NAME="mds" -EXE_NAME="${BINARY_NAME}.exe" +CARGO_EXE="${BINARY_NAME}.exe" +DIST_EXE="Markdown-Editor.exe" # Parse arguments -BUILD_TYPE="${1:-gnu}" +BUILD_TYPE="gnu" +SKIP_CLIENT=false +for arg in "$@"; do + case "$arg" in + --gnu|--msvc) BUILD_TYPE="${arg#--}" ;; + --skip-client) SKIP_CLIENT=true ;; + esac +done # 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" +echo "Building Markdown-Editor v${VERSION} for Windows..." + +# Step 1: Build client for local bundle +if [ "$SKIP_CLIENT" = false ]; then + echo "" + echo "Building client for local bundle..." + rm -rf "$CLIENT_DIST_DIR" + cd "$PROJECT_ROOT" + SERVER_PORT= CLIENT_DIST_PATH="$CLIENT_DIST_DIR" pnpm --filter client build + if [ ! -f "$CLIENT_DIST_DIR/index.html" ]; then + echo "ERROR: Client build did not produce index.html" 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" + echo "✓ Client built to $CLIENT_DIST_DIR" +else + if [ ! -d "$CLIENT_DIST_DIR" ]; then + echo "ERROR: --skip-client specified but no pre-built client at $CLIENT_DIST_DIR" exit 1 fi - - echo "✓ Dependencies found" + echo "Using pre-built client from $CLIENT_DIST_DIR" +fi + +# Step 2: Generate Windows icon from logo SVG +ICON_SVG="${PROJECT_ROOT}/client/public/logo.svg" +ICO_PATH="${CRATES_DIR}/cli/assets/icon.ico" +if [ ! -f "$ICO_PATH" ] && [ -f "$ICON_SVG" ]; then + echo "Generating Windows icon..." + TEMP_DIR=$(mktemp -d) + SOURCE_PNG="${TEMP_DIR}/icon_256.png" + + if command -v rsvg-convert >/dev/null 2>&1; then + rsvg-convert -w 256 -h 256 "$ICON_SVG" -o "$SOURCE_PNG" + else + qlmanage -t -s 256 -o "$TEMP_DIR" "$ICON_SVG" 2>/dev/null + mv "${TEMP_DIR}/logo.svg.png" "$SOURCE_PNG" 2>/dev/null || true + fi + + if [ -f "$SOURCE_PNG" ]; then + mkdir -p "$(dirname "$ICO_PATH")" + node -e " + const fs = require('fs'); + const png = fs.readFileSync('${SOURCE_PNG}'); + const hdr = Buffer.alloc(6); + hdr.writeUInt16LE(0,0); hdr.writeUInt16LE(1,2); hdr.writeUInt16LE(1,4); + const ent = Buffer.alloc(16); + ent.writeUInt8(0,0); ent.writeUInt8(0,1); ent.writeUInt8(0,2); ent.writeUInt8(0,3); + ent.writeUInt16LE(1,4); ent.writeUInt16LE(32,6); + ent.writeUInt32LE(png.length,8); ent.writeUInt32LE(22,12); + fs.writeFileSync('${ICO_PATH}', Buffer.concat([hdr, ent, png])); + " + echo "✓ Windows icon generated" + else + echo "Warning: Could not convert SVG to PNG for icon." + fi + rm -rf "$TEMP_DIR" fi -# Build based on selected type +# Step 3: Build server binary +cd "$CRATES_DIR" + +echo "Checking dependencies..." + case "$BUILD_TYPE" in - --gnu|gnu) + gnu) + 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 + + 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" + exit 1 + fi + + echo "✓ Dependencies found" 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}" + BINARY_PATH="target/x86_64-pc-windows-gnu/release/${CARGO_EXE}" ;; - --msvc) + 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}" + BINARY_PATH="target/x86_64-pc-windows-msvc/release/${CARGO_EXE}" ;; *) echo "Unknown build type: $BUILD_TYPE" @@ -65,36 +122,40 @@ case "$BUILD_TYPE" in ;; 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..." +# Step 4: Package binary + client into zip +echo "Packaging Windows distribution..." mkdir -p "$DIST_DIR" -# Clean up previous build -rm -f "${DIST_DIR}/${EXE_NAME}" -rm -f "${DIST_DIR}/mds-windows.zip" +STAGING_DIR="${DIST_DIR}/markdown-editor-windows-staging" +rm -rf "$STAGING_DIR" +mkdir -p "$STAGING_DIR" -# Copy binary to dist -echo "Copying binary to dist..." -cp "$BINARY_PATH" "${DIST_DIR}/${EXE_NAME}" +# Copy binary (rename to Markdown-Editor.exe) +cp "$BINARY_PATH" "${STAGING_DIR}/${DIST_EXE}" + +# Copy client assets +if [ -d "$CLIENT_DIST_DIR" ]; then + echo "Bundling client assets..." + cp -r "$CLIENT_DIST_DIR" "${STAGING_DIR}/client" +fi # Create zip archive +rm -f "${DIST_DIR}/markdown-editor-windows.zip" echo "Creating zip archive..." -cd "$DIST_DIR" -zip -q "mds-windows.zip" "${EXE_NAME}" +cd "$STAGING_DIR" +zip -r "${DIST_DIR}/markdown-editor-windows.zip" . + +# Clean up staging +rm -rf "$STAGING_DIR" 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)" - +echo " Build type: ${BUILD_TYPE}" +echo " Zip file: ${DIST_DIR}/markdown-editor-windows.zip" +echo " Client: bundled in client/" diff --git a/crates/build.js b/crates/build.js index 9e31d0c..d1a2baf 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,170 @@ 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); + } + } +} + +/** + * 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(icoPath)) { + console.log('Using existing icon.ico'); + return; + } + + if (!fs.existsSync(svgPath)) { + console.warn('Warning: logo.svg not found, skipping Windows icon generation.'); + return; + } + + 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); + } + } + } + + if (!fs.existsSync(pngPath)) { + console.warn('Warning: Could not convert SVG to PNG for Windows icon.'); + return; + } + + 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'); + } catch (err) { + console.warn('Warning: Windows icon generation failed:', err.message); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + 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(); + generateWindowsIcon(); 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 +264,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 +280,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 +346,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 +363,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 +376,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 +394,67 @@ function buildMacOS(buildType = 'universal') { } } - // Create macOS app bundle createMacOSAppBundle(binaryPath, appName, version); } +function generateMacOSIcon(resourcesPath) { + if (os.platform() !== 'darwin') return; + + const svgPath = path.join(PROJECT_ROOT, 'client', 'public', 'logo.svg'); + if (!fs.existsSync(svgPath)) { + console.warn('Warning: logo.svg not found, skipping icon generation.'); + return; + } + + console.log('Generating 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.warn('Warning: Could not convert SVG to PNG. Install librsvg (brew install librsvg) for icon support.'); + return; + } + + 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 = path.join(resourcesPath, 'AppIcon.icns'); + execSync(`iconutil -c icns -o "${icnsPath}" "${iconsetDir}"`, { stdio: 'pipe' }); + console.log('✓ App icon generated'); + } catch (err) { + console.warn('Warning: Icon generation failed:', err.message); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + function createMacOSAppBundle(binaryPath, appName, version) { console.log('Creating app bundle...'); @@ -221,6 +480,12 @@ function createMacOSAppBundle(binaryPath, appName, version) { fs.copyFileSync(binaryPath, path.join(macosPath, BINARY_NAME)); fs.chmodSync(path.join(macosPath, BINARY_NAME), 0o755); + // Generate and copy app icon + generateMacOSIcon(resourcesPath); + + // Copy client assets into Resources/client/ + copyClientAssets(path.join(resourcesPath, 'client')); + // Create Info.plist const infoPlist = ` @@ -233,7 +498,9 @@ function createMacOSAppBundle(binaryPath, appName, version) { CFBundleName ${appName} CFBundleDisplayName - Markdown Editor Server + Markdown Editor + CFBundleIconFile + AppIcon CFBundleVersion ${version} CFBundleShortVersionString @@ -257,12 +524,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 +535,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 +563,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..67d92d8 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -13,6 +13,7 @@ anyhow = { workspace = true } clap = { workspace = true } dirs = { workspace = true } nix = { workspace = true } +open = "5" sysinfo = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } @@ -25,3 +26,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/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/commands/install.rs b/crates/cli/src/commands/install.rs index e509dc7..9c7ea97 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -1,173 +1,72 @@ -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}; +/// Add the binary's current directory to PATH so `mds` can be used as a CLI command. +/// On macOS: creates a symlink at `/usr/local/bin/mds`, or falls back to shell config. +/// On Windows: adds the exe directory to the user's PATH registry entry. +pub fn add_to_path() -> Result<()> { + let exe_path = std::env::current_exe().context("Failed to get current executable path")?; + let exe_dir = exe_path + .parent() + .context("Failed to get executable directory")?; -/// 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 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") - } -} - -/// 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..."); - } - - 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 - } - } - - // 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()); - Ok(()) + add_to_path_inner(exe_dir, &exe_path) } -/// Add the install directory to PATH #[cfg(target_os = "macos")] -fn add_to_path(install_dir: &Path) -> Result<()> { +fn add_to_path_inner(exe_dir: &Path, exe_path: &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() { - println!( - "Created symlink: {} -> {}", - symlink_path.display(), - install_path.display() - ); - return Ok(()); + if symlink_path.exists() || symlink_path.is_symlink() { + if let Ok(target) = std::fs::read_link(symlink_path) { + if target == exe_path { + return Ok(()); + } } - // Symlink failed (likely no permissions), fall back to shell config - } else { - println!("Symlink already exists at {}", symlink_path.display()); + if std::fs::remove_file(symlink_path).is_ok() { + if std::os::unix::fs::symlink(exe_path, symlink_path).is_ok() { + println!( + "Updated symlink: {} -> {}", + symlink_path.display(), + exe_path.display() + ); + return Ok(()); + } + } + } else if std::os::unix::fs::symlink(exe_path, symlink_path).is_ok() { + println!( + "Created symlink: {} -> {}", + symlink_path.display(), + exe_path.display() + ); return Ok(()); } - // Fall back to adding to shell config - add_to_shell_config(install_dir)?; + // Symlink approach failed (likely due to permissions), fall back to shell config + add_to_shell_config(exe_dir)?; Ok(()) } -/// Add to shell config files (.zshrc, .bashrc) #[cfg(target_os = "macos")] -fn add_to_shell_config(install_dir: &Path) -> Result<()> { +fn add_to_shell_config(exe_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() + exe_dir.display() ); - // 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!("Added {} to PATH in shell config", exe_dir.display()); println!("Note: Open a new terminal for the 'mds' command to be available"); Ok(()) @@ -175,14 +74,13 @@ 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(()); } - let mut file = fs::OpenOptions::new() + let mut file = std::fs::OpenOptions::new() .create(true) .append(true) .open(file_path) @@ -192,9 +90,8 @@ 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_dir: &Path, _exe_path: &Path) -> Result<()> { use winreg::RegKey; use winreg::enums::*; @@ -204,134 +101,27 @@ fn add_to_path(install_dir: &Path) -> Result<()> { .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(()) } 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..07a4e06 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()?; @@ -107,19 +136,17 @@ fn start_daemon(host: String, port: u16, pid_file: &PathBuf) -> Result<()> { 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 created = system_commands::create_windows_service( "MarkdownEditorServer", &exe_path.to_string_lossy(), "Markdown Editor Server", - "demand", // Manual start + "demand", )?; if !created { @@ -128,17 +155,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/main.rs b/crates/cli/src/main.rs index 4bb75c8..c2bc273 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -2,16 +2,18 @@ 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::constants::default_pid_file; +use crate::utils::{is_process_running, read_pid_file}; #[cfg(target_os = "windows")] use std::ffi::OsString; @@ -57,9 +59,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 +68,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 +76,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 +127,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 +148,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,7 +177,6 @@ fn main() -> Result<()> { let cli = Cli::parse(); - // Handle location flag first if cli.location { cmd_location()?; return Ok(()); @@ -178,10 +184,57 @@ fn main() -> Result<()> { match cli.command { None => { - // Install anyway if exists, just overwrite - cmd_install()?; + // Quick launch: add to PATH, check if running, start daemon, open browser + let _ = add_to_path(); // best-effort, don't fail if PATH update fails + + 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 = std::env::current_exe()?; + 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 +245,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..847fdba 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()); @@ -240,93 +115,3 @@ 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(()); -} diff --git a/crates/cli/src/utils/system_commands.rs b/crates/cli/src/utils/system_commands.rs index d55c947..ae783a9 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, @@ -37,8 +34,8 @@ pub fn create_windows_service( /// Start a Windows service #[cfg(target_os = "windows")] -pub fn start_windows_service(service_name: &str) -> Result { - let status = Command::new("sc.exe") +pub fn start_windows_service(service_name: &str) -> anyhow::Result { + let status = std::process::Command::new("sc.exe") .args(["start", service_name]) .status()?; @@ -47,90 +44,30 @@ pub fn start_windows_service(service_name: &str) -> Result Result { - let status = Command::new("sc.exe") +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) } -/// Delete 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]) - .status()?; - - Ok(status) -} - -/// Query Windows service configuration -#[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()?; - - Ok(output) -} - /// 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/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/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 Date: Tue, 3 Mar 2026 13:00:28 +0800 Subject: [PATCH 02/20] chore: remove legacy build shell scripts --- crates/BUILD.md | 21 ++-- crates/build-macos.sh | 227 ---------------------------------------- crates/build-windows.sh | 161 ---------------------------- crates/package.json | 4 +- 4 files changed, 14 insertions(+), 399 deletions(-) delete mode 100755 crates/build-macos.sh delete mode 100755 crates/build-windows.sh diff --git a/crates/BUILD.md b/crates/BUILD.md index a48af5f..3951dd7 100644 --- a/crates/BUILD.md +++ b/crates/BUILD.md @@ -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. @@ -110,6 +113,7 @@ All builds create artifacts in the `dist/` directory: ## 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/build-macos.sh b/crates/build-macos.sh deleted file mode 100755 index bb483d9..0000000 --- a/crates/build-macos.sh +++ /dev/null @@ -1,227 +0,0 @@ -#!/bin/bash -set -e - -# Build script for creating macOS .app bundle with bundled client -# Usage: ./crates/build-macos.sh [--intel|--arm|--universal] [--skip-client] -# --intel Build for Intel Macs only (x86_64) -# --arm Build for Apple Silicon only (aarch64) -# --universal Build universal binary (default, works on both) -# --skip-client Skip building the client (use pre-built client assets) - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -CRATES_DIR="${PROJECT_ROOT}/crates" -DIST_DIR="${PROJECT_ROOT}/dist" -CLIENT_DIST_DIR="${PROJECT_ROOT}/dist-local" -APP_NAME="Markdown-Editor" -BINARY_NAME="mds" -BUNDLE_ID="com.markdown-editor.mds" - -# Parse arguments -BUILD_TYPE="universal" -SKIP_CLIENT=false -for arg in "$@"; do - case "$arg" in - --intel|--arm|--universal) BUILD_TYPE="${arg#--}" ;; - --skip-client) SKIP_CLIENT=true ;; - esac -done - -# 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 Markdown-Editor v${VERSION}..." - -# Step 1: Build client for local bundle -if [ "$SKIP_CLIENT" = false ]; then - echo "" - echo "Building client for local bundle..." - rm -rf "$CLIENT_DIST_DIR" - cd "$PROJECT_ROOT" - SERVER_PORT= CLIENT_DIST_PATH="$CLIENT_DIST_DIR" pnpm --filter client build - if [ ! -f "$CLIENT_DIST_DIR/index.html" ]; then - echo "ERROR: Client build did not produce index.html" - exit 1 - fi - echo "✓ Client built to $CLIENT_DIST_DIR" -else - if [ ! -d "$CLIENT_DIST_DIR" ]; then - echo "ERROR: --skip-client specified but no pre-built client at $CLIENT_DIST_DIR" - exit 1 - fi - echo "Using pre-built client from $CLIENT_DIST_DIR" -fi - -# Step 2: Build server binary -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}..." - - if [ "$target" != "$HOST_TARGET" ]; then - 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 - -# Step 3: Generate app icon from logo SVG -echo "Generating app icon..." -ICON_SVG="${PROJECT_ROOT}/client/public/logo.svg" -ICON_ICNS="" -if [ -f "$ICON_SVG" ]; then - TEMP_DIR=$(mktemp -d) - ICONSET_DIR="${TEMP_DIR}/AppIcon.iconset" - mkdir -p "$ICONSET_DIR" - - SOURCE_PNG="${TEMP_DIR}/icon_1024.png" - if command -v rsvg-convert >/dev/null 2>&1; then - rsvg-convert -w 1024 -h 1024 "$ICON_SVG" -o "$SOURCE_PNG" - else - qlmanage -t -s 1024 -o "$TEMP_DIR" "$ICON_SVG" 2>/dev/null - mv "${TEMP_DIR}/logo.svg.png" "$SOURCE_PNG" 2>/dev/null || true - fi - - if [ -f "$SOURCE_PNG" ]; then - sips -z 16 16 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_16x16.png" >/dev/null 2>&1 - sips -z 32 32 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_16x16@2x.png" >/dev/null 2>&1 - sips -z 32 32 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_32x32.png" >/dev/null 2>&1 - sips -z 64 64 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_32x32@2x.png" >/dev/null 2>&1 - sips -z 128 128 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_128x128.png" >/dev/null 2>&1 - sips -z 256 256 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_128x128@2x.png" >/dev/null 2>&1 - sips -z 256 256 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_256x256.png" >/dev/null 2>&1 - sips -z 512 512 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_256x256@2x.png" >/dev/null 2>&1 - sips -z 512 512 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_512x512.png" >/dev/null 2>&1 - sips -z 1024 1024 "$SOURCE_PNG" --out "${ICONSET_DIR}/icon_512x512@2x.png" >/dev/null 2>&1 - - ICON_ICNS="${TEMP_DIR}/AppIcon.icns" - iconutil -c icns -o "$ICON_ICNS" "$ICONSET_DIR" - echo "✓ App icon generated" - else - echo "Warning: Could not convert SVG to PNG. Install librsvg (brew install librsvg) for icon support." - fi -fi - -# Step 4: Create app bundle -echo "Creating app bundle..." - -mkdir -p "$DIST_DIR" - -# Clean up previous build -rm -rf "${DIST_DIR}/${APP_NAME}.app" -rm -f "${DIST_DIR}/markdown-editor-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/" - -# Copy app icon -if [ -n "$ICON_ICNS" ] && [ -f "$ICON_ICNS" ]; then - cp "$ICON_ICNS" "${DIST_DIR}/${APP_NAME}.app/Contents/Resources/AppIcon.icns" -fi - -# Copy client assets into Resources/client/ -if [ -d "$CLIENT_DIST_DIR" ]; then - echo "Bundling client assets..." - cp -r "$CLIENT_DIST_DIR" "${DIST_DIR}/${APP_NAME}.app/Contents/Resources/client" -fi - -# 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 - CFBundleIconFile - AppIcon - CFBundleVersion - ${VERSION} - CFBundleShortVersionString - ${VERSION} - CFBundlePackageType - APPL - LSUIElement - - LSMinimumSystemVersion - 10.13 - NSHighResolutionCapable - - - -EOF - -# Create PkgInfo -echo -n "APPL????" > "${DIST_DIR}/${APP_NAME}.app/Contents/PkgInfo" - -# Clean up icon temp files -if [ -n "$TEMP_DIR" ] && [ -d "$TEMP_DIR" ]; then - rm -rf "$TEMP_DIR" -fi - -echo "Creating zip archive..." -cd "$DIST_DIR" -zip -r "markdown-editor-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}/markdown-editor-macos.zip" -echo " Client: bundled in Resources/client/" diff --git a/crates/build-windows.sh b/crates/build-windows.sh deleted file mode 100755 index 243d3e9..0000000 --- a/crates/build-windows.sh +++ /dev/null @@ -1,161 +0,0 @@ -#!/bin/bash -set -e - -# Build script for creating Windows executable with bundled client -# Usage: ./crates/build-windows.sh [--gnu|--msvc] [--skip-client] -# --gnu Build using GNU toolchain (cross-compile from macOS/Linux, default) -# --msvc Build using MSVC toolchain (requires Windows or special setup) -# --skip-client Skip building the client (use pre-built client assets) - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -CRATES_DIR="${PROJECT_ROOT}/crates" -DIST_DIR="${PROJECT_ROOT}/dist" -CLIENT_DIST_DIR="${PROJECT_ROOT}/dist-local" -BINARY_NAME="mds" -CARGO_EXE="${BINARY_NAME}.exe" -DIST_EXE="Markdown-Editor.exe" - -# Parse arguments -BUILD_TYPE="gnu" -SKIP_CLIENT=false -for arg in "$@"; do - case "$arg" in - --gnu|--msvc) BUILD_TYPE="${arg#--}" ;; - --skip-client) SKIP_CLIENT=true ;; - esac -done - -# 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 Markdown-Editor v${VERSION} for Windows..." - -# Step 1: Build client for local bundle -if [ "$SKIP_CLIENT" = false ]; then - echo "" - echo "Building client for local bundle..." - rm -rf "$CLIENT_DIST_DIR" - cd "$PROJECT_ROOT" - SERVER_PORT= CLIENT_DIST_PATH="$CLIENT_DIST_DIR" pnpm --filter client build - if [ ! -f "$CLIENT_DIST_DIR/index.html" ]; then - echo "ERROR: Client build did not produce index.html" - exit 1 - fi - echo "✓ Client built to $CLIENT_DIST_DIR" -else - if [ ! -d "$CLIENT_DIST_DIR" ]; then - echo "ERROR: --skip-client specified but no pre-built client at $CLIENT_DIST_DIR" - exit 1 - fi - echo "Using pre-built client from $CLIENT_DIST_DIR" -fi - -# Step 2: Generate Windows icon from logo SVG -ICON_SVG="${PROJECT_ROOT}/client/public/logo.svg" -ICO_PATH="${CRATES_DIR}/cli/assets/icon.ico" -if [ ! -f "$ICO_PATH" ] && [ -f "$ICON_SVG" ]; then - echo "Generating Windows icon..." - TEMP_DIR=$(mktemp -d) - SOURCE_PNG="${TEMP_DIR}/icon_256.png" - - if command -v rsvg-convert >/dev/null 2>&1; then - rsvg-convert -w 256 -h 256 "$ICON_SVG" -o "$SOURCE_PNG" - else - qlmanage -t -s 256 -o "$TEMP_DIR" "$ICON_SVG" 2>/dev/null - mv "${TEMP_DIR}/logo.svg.png" "$SOURCE_PNG" 2>/dev/null || true - fi - - if [ -f "$SOURCE_PNG" ]; then - mkdir -p "$(dirname "$ICO_PATH")" - node -e " - const fs = require('fs'); - const png = fs.readFileSync('${SOURCE_PNG}'); - const hdr = Buffer.alloc(6); - hdr.writeUInt16LE(0,0); hdr.writeUInt16LE(1,2); hdr.writeUInt16LE(1,4); - const ent = Buffer.alloc(16); - ent.writeUInt8(0,0); ent.writeUInt8(0,1); ent.writeUInt8(0,2); ent.writeUInt8(0,3); - ent.writeUInt16LE(1,4); ent.writeUInt16LE(32,6); - ent.writeUInt32LE(png.length,8); ent.writeUInt32LE(22,12); - fs.writeFileSync('${ICO_PATH}', Buffer.concat([hdr, ent, png])); - " - echo "✓ Windows icon generated" - else - echo "Warning: Could not convert SVG to PNG for icon." - fi - rm -rf "$TEMP_DIR" -fi - -# Step 3: Build server binary -cd "$CRATES_DIR" - -echo "Checking dependencies..." - -case "$BUILD_TYPE" in - gnu) - 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 - - 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" - exit 1 - fi - - echo "✓ Dependencies found" - 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/${CARGO_EXE}" - ;; - 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/${CARGO_EXE}" - ;; - *) - echo "Unknown build type: $BUILD_TYPE" - echo "Use --gnu (default) or --msvc" - exit 1 - ;; -esac - -if [ ! -f "$BINARY_PATH" ]; then - echo "ERROR: Binary not found at $BINARY_PATH" - exit 1 -fi - -# Step 4: Package binary + client into zip -echo "Packaging Windows distribution..." -mkdir -p "$DIST_DIR" - -STAGING_DIR="${DIST_DIR}/markdown-editor-windows-staging" -rm -rf "$STAGING_DIR" -mkdir -p "$STAGING_DIR" - -# Copy binary (rename to Markdown-Editor.exe) -cp "$BINARY_PATH" "${STAGING_DIR}/${DIST_EXE}" - -# Copy client assets -if [ -d "$CLIENT_DIST_DIR" ]; then - echo "Bundling client assets..." - cp -r "$CLIENT_DIST_DIR" "${STAGING_DIR}/client" -fi - -# Create zip archive -rm -f "${DIST_DIR}/markdown-editor-windows.zip" -echo "Creating zip archive..." -cd "$STAGING_DIR" -zip -r "${DIST_DIR}/markdown-editor-windows.zip" . - -# Clean up staging -rm -rf "$STAGING_DIR" - -echo "" -echo "✓ Build complete!" -echo " Version: ${VERSION}" -echo " Build type: ${BUILD_TYPE}" -echo " Zip file: ${DIST_DIR}/markdown-editor-windows.zip" -echo " Client: bundled in client/" diff --git a/crates/package.json b/crates/package.json index 5e931d0..5ac0a51 100644 --- a/crates/package.json +++ b/crates/package.json @@ -17,8 +17,6 @@ "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", @@ -26,4 +24,4 @@ "link": "cargo make install", "unlink": "cargo uninstall md-server" } -} +} \ No newline at end of file From af279e7df5c4d48ead765a83a19152105899c468 Mon Sep 17 00:00:00 2001 From: s-elo Date: Tue, 3 Mar 2026 20:14:53 +0800 Subject: [PATCH 03/20] feat: restart window services for created service --- crates/cli/src/commands/install.rs | 23 +++++++++++++++++++++++ crates/cli/src/commands/start.rs | 12 +++++++++--- crates/cli/src/utils/system_commands.rs | 24 ++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/commands/install.rs b/crates/cli/src/commands/install.rs index 9c7ea97..039b0c0 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -4,6 +4,8 @@ use std::path::Path; use anyhow::{Context, Result}; +// use crate::utils::system_commands; + /// Add the binary's current directory to PATH so `mds` can be used as a CLI command. /// On macOS: creates a symlink at `/usr/local/bin/mds`, or falls back to shell config. /// On Windows: adds the exe directory to the user's PATH registry entry. @@ -125,3 +127,24 @@ fn add_to_path_inner(exe_dir: &Path, _exe_path: &Path) -> Result<()> { 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/start.rs b/crates/cli/src/commands/start.rs index 07a4e06..a9acf83 100644 --- a/crates/cli/src/commands/start.rs +++ b/crates/cli/src/commands/start.rs @@ -139,9 +139,15 @@ fn start_daemon(host: String, port: u16, pid_file: &PathBuf) -> Result<()> { let service_exists = system_commands::query_windows_service("MarkdownEditorServer").unwrap_or(false); - if !service_exists { - println!("Service not installed, registering service..."); - let exe_path = std::env::current_exe()?; + let exe_path = std::env::current_exe()?; + 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(), diff --git a/crates/cli/src/utils/system_commands.rs b/crates/cli/src/utils/system_commands.rs index ae783a9..e733b9e 100644 --- a/crates/cli/src/utils/system_commands.rs +++ b/crates/cli/src/utils/system_commands.rs @@ -32,6 +32,20 @@ pub fn create_windows_service( Ok(create_status.success()) } +#[cfg(target_os = "windows")] +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(update_status.success()) +} + /// Start a Windows service #[cfg(target_os = "windows")] pub fn start_windows_service(service_name: &str) -> anyhow::Result { @@ -52,6 +66,16 @@ pub fn stop_windows_service(service_name: &str) -> anyhow::Result anyhow::Result { +// let status = std::process::Command::new("sc.exe") +// .args(["delete", service_name]) +// .status()?; + +// Ok(status) +// } + /// Query extended Windows service information (including PID) #[cfg(target_os = "windows")] pub fn query_windows_service_ex(service_name: &str) -> anyhow::Result { From a605db33720285ddf9a2852400be4c0d7f80069c Mon Sep 17 00:00:00 2001 From: s-elo Date: Wed, 4 Mar 2026 10:14:25 +0800 Subject: [PATCH 04/20] feat: generate server icon in advance --- .gitignore | 3 - crates/build.js | 144 +++++---------------------- crates/cli/assets/AppIcon.icns | Bin 0 -> 73273 bytes crates/cli/assets/icon.ico | Bin 0 -> 3137 bytes crates/generate-icons.js | 176 +++++++++++++++++++++++++++++++++ crates/package.json | 4 +- 6 files changed, 201 insertions(+), 126 deletions(-) create mode 100644 crates/cli/assets/AppIcon.icns create mode 100644 crates/cli/assets/icon.ico create mode 100755 crates/generate-icons.js diff --git a/.gitignore b/.gitignore index e41a0f8..bd9727c 100644 --- a/.gitignore +++ b/.gitignore @@ -90,6 +90,3 @@ editor-settings.json Markdown-Editor.app markdown-editor-macos.zip markdown-editor-windows.zip - -# Generated build assets -crates/cli/assets/icon.ico diff --git a/crates/build.js b/crates/build.js index d1a2baf..ebe1202 100755 --- a/crates/build.js +++ b/crates/build.js @@ -166,74 +166,15 @@ function copyDirSync(src, dest) { } /** - * Generate a .ico file for the Windows exe from logo.svg. - * ICO format wraps a PNG image (supported since Windows Vista). + * Check if Windows icon exists, warn if not found. */ -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(icoPath)) { - console.log('Using existing icon.ico'); - return; - } - - if (!fs.existsSync(svgPath)) { - console.warn('Warning: logo.svg not found, skipping Windows icon generation.'); - return; - } - - 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); - } - } - } - - if (!fs.existsSync(pngPath)) { - console.warn('Warning: Could not convert SVG to PNG for Windows icon.'); - return; - } - - 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'); - } catch (err) { - console.warn('Warning: Windows icon generation failed:', err.message); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); +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'); } } @@ -242,7 +183,7 @@ function buildWindows(useGnu = true) { console.log(`\nBuilding Markdown-Editor v${version} for Windows...\n`); buildClient(); - generateWindowsIcon(); + checkWindowsIcon(); if (useGnu) { if (!checkRustTarget('x86_64-pc-windows-gnu', 'rustup target add x86_64-pc-windows-gnu')) { @@ -397,62 +338,21 @@ function buildMacOS(buildType = 'universal') { createMacOSAppBundle(binaryPath, appName, version); } -function generateMacOSIcon(resourcesPath) { - if (os.platform() !== 'darwin') return; +/** + * 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'); - const svgPath = path.join(PROJECT_ROOT, 'client', 'public', 'logo.svg'); - if (!fs.existsSync(svgPath)) { - console.warn('Warning: logo.svg not found, skipping icon generation.'); + 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; } - console.log('Generating 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.warn('Warning: Could not convert SVG to PNG. Install librsvg (brew install librsvg) for icon support.'); - return; - } - - 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 = path.join(resourcesPath, 'AppIcon.icns'); - execSync(`iconutil -c icns -o "${icnsPath}" "${iconsetDir}"`, { stdio: 'pipe' }); - console.log('✓ App icon generated'); - } catch (err) { - console.warn('Warning: Icon generation failed:', err.message); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } + fs.copyFileSync(sourceIcnsPath, destIcnsPath); + console.log('Using pre-generated AppIcon.icns'); } function createMacOSAppBundle(binaryPath, appName, version) { @@ -480,8 +380,8 @@ function createMacOSAppBundle(binaryPath, appName, version) { fs.copyFileSync(binaryPath, path.join(macosPath, BINARY_NAME)); fs.chmodSync(path.join(macosPath, BINARY_NAME), 0o755); - // Generate and copy app icon - generateMacOSIcon(resourcesPath); + // Copy pre-generated app icon + copyMacOSIcon(resourcesPath); // Copy client assets into Resources/client/ copyClientAssets(path.join(resourcesPath, 'client')); diff --git a/crates/cli/assets/AppIcon.icns b/crates/cli/assets/AppIcon.icns new file mode 100644 index 0000000000000000000000000000000000000000..dca5b771f24d2449597f67c593e3f9a88ac142d0 GIT binary patch literal 73273 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 literal 0 HcmV?d00001 diff --git a/crates/cli/assets/icon.ico b/crates/cli/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..334fa3df8fea436de6c0153924e0fa73893a151b GIT binary patch literal 3137 zcmb_edpJ~E8((|&FvBn!A!CTlIGsw!rKn_b8x%Pxj>w24h3O*7Z6+Ki5m6_a5~sp-)a;sWO6ek09D5|)T*keda!#(Zs;~6?nq@rmfzjwW3UMu|LgIXITtEi)$ujUa4I1}?XpHj+=4m1iTNWL zk|VC`8LDPgjlP*giUAH6eCW{QO`okzT$h~k!X`-Q;A<nuvG9@9-Ig};g)Z@%p-G_N%gHH{P^1&$H(fHaewwR+-(;H zKg`c@wPuK0nr=9IR1H~xXd=3!atF^3#UKugucQ>VfJ(ni z0M&lc-&>yRbi6yH15LqcAwu9JGYE7K3A zf4+Hid#=^h%{PG|DOW4$FQv*FUFnst(Q2H98OqHmv>@MRpZC__RlPM>$D7xG;7u}b z2^^Z%Z64m(!4uWyJavx(6+5?qI3tA?CY+2{Y8iS3a)wa zjhJbW_9)_pDY7!=@=6u-5%a699d1Fkh!cR+w>c-MBfZ?630ywU5+7nyp)c0rFwwC# z>jZUXG>J>APRDQ66txBNVKA1@71w#yI|APxqP_e=h&sfb#)>X*sX!Y%qDwFgX{*X+Ra)3d>@ z|F47$z-x|(>(tNB0aao69Es?BZ|c2$no@su_-oqXNL?YMj}e{oFay44i8^AXIA?8T%%?U#g`S9&?%v0l9l;8oPV9#~s!D+4Z@TJnZr}i?eK1t{K5atUOuyEA`(CdbX z6@Q=Flfb?$4vTJi4gGs_M4}rkcBjPB))c3TdY7A`QmxurihjZ*sXm_z700zhRYLJNFiE05J<61cwd__e`X#~*A4eE6 z8I)K?77dH^cFfon4O^0$YQ;{?prthqb(VdU`CIT)jBRi?d1JixBaU(!Z*R3kO%$hr z$Q|TYw-pUXxUrW??|`pOr0jG9xXkxT^TcISP72rVsJhVIvi??eyhSNKz=OJbgGT$t zh}zNCNT3t!9!!ZIkY+%>hSVLdWiP~9s?3qdYpZa1#%Wd{XUVxUl<51j<`n8+4s+*k z#v0tm$6g#IUW=K$1FBYID?r>)^I%&!8wAwmM!ATLFr*wJkM&lc{fq<;s zDA%&rv&^rXs}k|SPP5LJau2$j9LADS_GMe!65OTL?|P0vzDAL4blfG!A2>8JfX0g? zf3LI#7m9LYObhyEoFxmR?xq*Cy-{qzwB~dleBr7e4Bgu@ z^O&;i8GkCUTkBqoRe_B@uycD5js5gDtw2m+t^})<2{vf6rH&rqZvXxfgRayJH{|L} zYTM^}Xjp}8ygRSMWtZS5g{#7^?_8($R=V8@@7u#;!I^o<<nK_3$IV*vpVQGYfja_7mvGOB!lMGkGe-J{_rJsrf^@QQ4&sfJ|2Sy%r)sjK=6xMz z&P{7jywpl)`@EmwJ)ws-Wb!*c01~eQwwRuWHhH8&eitmj7L%^iin*W6O-05?DDsv` zb0kmdO56HwY{1n)$+%0F=h;pu2Ms_?(c{QhrFLfnTb8kxN5F+~1=A~Olin0ASxB|N zd}YvM@e@5ZRalo~j;uj%d>N-#yY?{79?)Aq)tA=Cf`+TMvI@PH`6%8gHH#I|h$9ks zq4SvFWr(;>Djvv&dk9@yzU*h5`0x>#u09*|`~#m1mx7~9Bd}2;fv7F}UE7BJ$<76X zhygoLjbEtK<0B(z#qy`JwzLwz*>lwVYnOP$2yP+U5aXMvyCsHB_T(mF&4WsE(Wz)H zj+wBwW5vEz1glq5*sYEiKF+AduWHWk9WMFGS5*Uj4YXR=XZ~*g!d}`!V@2in)c0lC zvu*c zT;xpxP8uuiPep3W-pTMrI=AExC~$pT?-{7Q$hP5WUu*qH1pC#`q%0y55U;YS@T~dj z!}2j4*;JHRE|_}bqqsIxhuhqooeE3$oZ$b{OLHNkdPYH*_wB)>kgong?Dh{l-F#eY Io!N)}1>2E$yZ`_I literal 0 HcmV?d00001 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 5ac0a51..abc3eeb 100644 --- a/crates/package.json +++ b/crates/package.json @@ -22,6 +22,8 @@ "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 From 60e4c7eb6cd58b26b925a31172939b2b7bc959bf Mon Sep 17 00:00:00 2001 From: s-elo Date: Wed, 4 Mar 2026 11:00:34 +0800 Subject: [PATCH 05/20] feat: refine symlink for macos and location display --- crates/cli/src/commands/install.rs | 94 ++++++++++++++++++++--------- crates/cli/src/commands/location.rs | 9 ++- 2 files changed, 72 insertions(+), 31 deletions(-) diff --git a/crates/cli/src/commands/install.rs b/crates/cli/src/commands/install.rs index 039b0c0..252a8a6 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -7,57 +7,92 @@ use anyhow::{Context, Result}; // use crate::utils::system_commands; /// Add the binary's current directory to PATH so `mds` can be used as a CLI command. -/// On macOS: creates a symlink at `/usr/local/bin/mds`, or falls back to shell config. +/// 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 = std::env::current_exe().context("Failed to get current executable path")?; - let exe_dir = exe_path - .parent() - .context("Failed to get executable directory")?; - add_to_path_inner(exe_dir, &exe_path) + add_to_path_inner(&exe_path) } #[cfg(target_os = "macos")] -fn add_to_path_inner(exe_dir: &Path, exe_path: &Path) -> Result<()> { - let symlink_path = Path::new("/usr/local/bin/mds"); +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()); + } + + // 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"); + } + } + // Ensure ~/.local/bin is in PATH (whether symlink succeeded or not) + ensure_local_bin_in_path(&local_bin_dir)?; + Ok(()) +} + +/// 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 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 { - return Ok(()); - } - } - if std::fs::remove_file(symlink_path).is_ok() { - if std::os::unix::fs::symlink(exe_path, symlink_path).is_ok() { println!( - "Updated symlink: {} -> {}", + "✓ Symlink already exists and points to correct target: {} -> {}", symlink_path.display(), exe_path.display() ); - return Ok(()); + return Ok(()); // Already correctly linked } + println!( + "Updating existing symlink: {} -> {}", + symlink_path.display(), + exe_path.display() + ); } - } else if std::os::unix::fs::symlink(exe_path, symlink_path).is_ok() { - println!( - "Created symlink: {} -> {}", - symlink_path.display(), - exe_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() + ) + })?; } - // Symlink approach failed (likely due to permissions), fall back to shell config - add_to_shell_config(exe_dir)?; + // Create new symlink + std::os::unix::fs::symlink(exe_path, symlink_path) + .with_context(|| format!("Failed to create symlink: {}", symlink_path.display()))?; + Ok(()) } +/// Ensure ~/.local/bin is in PATH by adding it to shell config files. #[cfg(target_os = "macos")] -fn add_to_shell_config(exe_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", - exe_dir.display() + "\n# Add ~/.local/bin to PATH if not already present\nexport PATH=\"$HOME/.local/bin:$PATH\"\n" ); let zshrc = home.join(".zshrc"); @@ -68,7 +103,7 @@ fn add_to_shell_config(exe_dir: &Path) -> Result<()> { add_export_to_file(&bashrc, &export_line)?; } - println!("Added {} to PATH in shell config", exe_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(()) @@ -78,8 +113,9 @@ fn add_to_shell_config(exe_dir: &Path) -> Result<()> { fn add_export_to_file(file_path: &Path, export_line: &str) -> Result<()> { let content = std::fs::read_to_string(file_path).unwrap_or_default(); - 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 = std::fs::OpenOptions::new() 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(()) From f830aa0c9249eb91aaa9aaab7c8078d64b67f013 Mon Sep 17 00:00:00 2001 From: s-elo Date: Wed, 4 Mar 2026 16:27:16 +0800 Subject: [PATCH 06/20] feat: add server version mismatch check --- crates/Cargo.lock | 2 + crates/cli/Cargo.toml | 2 + crates/cli/src/check_server.rs | 92 ++++++++++++++++++++++++++++++ crates/cli/src/commands/install.rs | 4 +- crates/cli/src/commands/start.rs | 4 +- crates/cli/src/constants.rs | 6 ++ crates/cli/src/main.rs | 27 +++++---- crates/cli/src/utils/mod.rs | 7 +++ 8 files changed, 129 insertions(+), 15 deletions(-) create mode 100644 crates/cli/src/check_server.rs diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 5e4dd5b..f932b94 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -980,6 +980,8 @@ dependencies = [ "dirs", "nix", "open", + "serde", + "serde_json", "server", "sysinfo", "tokio", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 67d92d8..585fe34 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -19,6 +19,8 @@ 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 } 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 252a8a6..1e92f4c 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -4,7 +4,7 @@ use std::path::Path; use anyhow::{Context, Result}; -// use crate::utils::system_commands; +use crate::utils::get_real_executable_path; /// 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), @@ -12,7 +12,7 @@ use anyhow::{Context, Result}; /// 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 = std::env::current_exe().context("Failed to get current executable path")?; + let exe_path = get_real_executable_path().context("Failed to get current executable path")?; add_to_path_inner(&exe_path) } diff --git a/crates/cli/src/commands/start.rs b/crates/cli/src/commands/start.rs index a9acf83..6ab1616 100644 --- a/crates/cli/src/commands/start.rs +++ b/crates/cli/src/commands/start.rs @@ -132,14 +132,14 @@ 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); let service_exists = system_commands::query_windows_service("MarkdownEditorServer").unwrap_or(false); - 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}; 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 c2bc273..72fbb6f 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,3 +1,4 @@ +mod check_server; mod commands; mod constants; mod utils; @@ -12,8 +13,9 @@ use commands::{ }; use constants::{DEFAULT_HOST, DEFAULT_PORT}; +use crate::check_server::check_server; use crate::constants::default_pid_file; -use crate::utils::{is_process_running, read_pid_file}; +use crate::utils::{get_real_executable_path, is_process_running, read_pid_file}; #[cfg(target_os = "windows")] use std::ffi::OsString; @@ -183,19 +185,22 @@ fn main() -> Result<()> { } match cli.command { + // Quick launch: add to PATH, check if running, start daemon, open browser None => { - // Quick launch: add to PATH, check if running, start daemon, open browser let _ = add_to_path(); // best-effort, don't fail if PATH update fails - 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); + 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(()); } - return Ok(()); } } @@ -204,7 +209,7 @@ fn main() -> Result<()> { // 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 = std::env::current_exe()?; + let exe = get_real_executable_path()?; let _child = std::process::Command::new(&exe) .args([ "start", diff --git a/crates/cli/src/utils/mod.rs b/crates/cli/src/utils/mod.rs index 847fdba..22710e5 100644 --- a/crates/cli/src/utils/mod.rs +++ b/crates/cli/src/utils/mod.rs @@ -115,3 +115,10 @@ pub fn get_and_write_service_pid(pid_file: &PathBuf) -> Result<(), anyhow::Error 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) +} From ff31680017aab3d799df7e1a94d9771eb126d6d2 Mon Sep 17 00:00:00 2001 From: s-elo Date: Wed, 4 Mar 2026 17:01:37 +0800 Subject: [PATCH 07/20] fix: align with windows add_to_path_innner method --- crates/cli/src/commands/install.rs | 6 +++++- crates/cli/src/main.rs | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/commands/install.rs b/crates/cli/src/commands/install.rs index 1e92f4c..0a0199a 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -129,10 +129,14 @@ fn add_export_to_file(file_path: &Path, export_line: &str) -> Result<()> { } #[cfg(target_os = "windows")] -fn add_to_path_inner(exe_dir: &Path, _exe_path: &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) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 72fbb6f..1f0494d 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -187,6 +187,8 @@ fn main() -> Result<()> { match cli.command { // Quick launch: add to PATH, check if running, start daemon, open browser None => { + 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()?; From 640746b5634f1a1e339cbcde8b974a8710c52a67 Mon Sep 17 00:00:00 2001 From: s-elo Date: Wed, 4 Mar 2026 18:07:10 +0800 Subject: [PATCH 08/20] chore: add step to delete old pages artifact before uploading new one --- .github/workflows/build.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 17d62ab..55ac992 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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: From 267ac63dc84c083b605e4a426cd5d49cfcd68c51 Mon Sep 17 00:00:00 2001 From: s-elo Date: Wed, 4 Mar 2026 18:25:36 +0800 Subject: [PATCH 09/20] fix: default page path for different type docs --- client/src/components/EditorContainer/EditorContainer.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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) => { From d7204c9c85911166bce58c4960ccc3a848eaa971 Mon Sep 17 00:00:00 2001 From: s-elo Date: Wed, 4 Mar 2026 19:23:54 +0800 Subject: [PATCH 10/20] chore: update README with quick start guide and local server instructions; refactor editor documentation for clarity --- README.md | 17 +++++- client/src/components/Editor/DraftEditor.tsx | 5 +- .../components/Editor/configs/uploadConfig.ts | 10 ++-- .../components/Editor/internalDocs/guide.ts | 53 +++++++++++++++---- .../Editor/internalDocs/versionMismatch.ts | 10 +++- client/src/components/Footer/Footer.scss | 3 ++ client/src/components/Menu/Menu.tsx | 2 +- client/src/constants.ts | 4 ++ client/src/redux-api/docs.ts | 4 +- client/src/utils/utils.ts | 2 +- 10 files changed, 86 insertions(+), 24 deletions(-) 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/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 29d38cf..21d9696 100644 --- a/client/src/components/Editor/configs/uploadConfig.ts +++ b/client/src/components/Editor/configs/uploadConfig.ts @@ -4,15 +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'; - -function getApiBase() { - return SERVER_PORT ? `http://127.0.0.1:${SERVER_PORT}/api` : '/api'; -} +import { SERVER_BASE_URL } from '@/constants'; export function getImageUrl(url: string) { if (url.startsWith('/')) { - return `${getApiBase()}/imgs${url}`; + return `${SERVER_BASE_URL}/imgs${url}`; } return url; } @@ -20,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(`${getApiBase()}/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/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/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/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 f39ab83..f5c9f80 100644 --- a/client/src/redux-api/docs.ts +++ b/client/src/redux-api/docs.ts @@ -13,8 +13,10 @@ import { } from './docsApiType'; import { transformResponse, transformErrorResponse } from './interceptor'; +import { SERVER_BASE_URL } from '@/constants'; + const baseQuery = fetchBaseQuery({ - baseUrl: __SERVER_PORT__ ? `http://127.0.0.1:${__SERVER_PORT__}/api` : '/api', + baseUrl: SERVER_BASE_URL, }); export const docsApi = createApi({ diff --git a/client/src/utils/utils.ts b/client/src/utils/utils.ts index 8816ed2..eb26390 100644 --- a/client/src/utils/utils.ts +++ b/client/src/utils/utils.ts @@ -319,7 +319,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`; } From f3b586c8ec408994e2b50160a6adf62b71cbac46 Mon Sep 17 00:00:00 2001 From: s-elo Date: Wed, 4 Mar 2026 20:25:36 +0800 Subject: [PATCH 11/20] fix: drag popup, workspace switch and sync setting update for search service --- client/src/components/Menu/operations.tsx | 52 +++++++++++------------ client/src/components/Sidebar/Sidebar.tsx | 14 +++--- client/src/utils/utils.ts | 12 ++++-- crates/server/src/handlers/settings.rs | 4 ++ 4 files changed, 46 insertions(+), 36 deletions(-) diff --git a/client/src/components/Menu/operations.tsx b/client/src/components/Menu/operations.tsx index e7618ac..1667c70 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; @@ -350,6 +352,7 @@ export const usePasteDoc = () => { } to ${pasteParentPathArr.join('/') || 'root'}?`, })) ) { + await onCancel?.(); return; } @@ -420,33 +423,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 +431,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..365c89a 100644 --- a/client/src/components/Sidebar/Sidebar.tsx +++ b/client/src/components/Sidebar/Sidebar.tsx @@ -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/utils/utils.ts b/client/src/utils/utils.ts index eb26390..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) { diff --git a/crates/server/src/handlers/settings.rs b/crates/server/src/handlers/settings.rs index 01697cb..a99cc67 100644 --- a/crates/server/src/handlers/settings.rs +++ b/crates/server/src/handlers/settings.rs @@ -29,6 +29,10 @@ pub async fn update_settings_handler( state.services.doc_service.sync_settings(&updated_settings); state.services.git_service.sync_git(&updated_settings); + state + .services + .search_service + .sync_settings(&updated_settings); Ok(ApiRes::success(updated_settings)) } From 33889602e4f079f24da3a07785177c7dd8bd1f1e Mon Sep 17 00:00:00 2001 From: s-elo Date: Thu, 5 Mar 2026 11:31:07 +0800 Subject: [PATCH 12/20] chore: directly get settings from setting service --- client/src/redux-api/settings.ts | 2 +- crates/server/src/handlers/settings.rs | 11 +---- crates/server/src/services/doc/mod.rs | 67 +++++++++++--------------- crates/server/src/services/doc/test.rs | 20 +++++--- crates/server/src/services/git.rs | 2 + crates/server/src/services/search.rs | 40 +++++++-------- 6 files changed, 65 insertions(+), 77 deletions(-) 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/crates/server/src/handlers/settings.rs b/crates/server/src/handlers/settings.rs index a99cc67..79de75a 100644 --- a/crates/server/src/handlers/settings.rs +++ b/crates/server/src/handlers/settings.rs @@ -22,17 +22,10 @@ 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); - state - .services - .search_service - .sync_settings(&updated_settings); Ok(ApiRes::success(updated_settings)) } diff --git a/crates/server/src/services/doc/mod.rs b/crates/server/src/services/doc/mod.rs index 9338ae4..29e785c 100644 --- a/crates/server/src/services/doc/mod.rs +++ b/crates/server/src/services/doc/mod.rs @@ -11,34 +11,18 @@ pub use structs::{ }; use crate::services::settings::SettingsService; -use std::{ - fs, - path::PathBuf, - sync::{Arc, Mutex}, -}; +use std::{fs, path::PathBuf, sync::Arc}; pub struct DocService { - ignore_dirs: Arc>>, - 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..e5a2cea 100644 --- a/crates/server/src/services/git.rs +++ b/crates/server/src/services/git.rs @@ -348,6 +348,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/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() + } } From 0e71dc4ca4f55bd584e78fc5ce584886f70b198b Mon Sep 17 00:00:00 2001 From: s-elo Date: Thu, 5 Mar 2026 11:36:42 +0800 Subject: [PATCH 13/20] chore: remove legacy imgStoreApi --- .../ImgManagement/ImgManagement.tsx | 2 +- .../src/components/UploadImg/UploadImg.scss | 51 ----- client/src/components/UploadImg/UploadImg.tsx | 176 ------------------ .../src/redux-api/{imgStoreApi.ts => img.ts} | 46 +---- 4 files changed, 2 insertions(+), 273 deletions(-) delete mode 100644 client/src/components/UploadImg/UploadImg.scss delete mode 100644 client/src/components/UploadImg/UploadImg.tsx rename client/src/redux-api/{imgStoreApi.ts => img.ts} (56%) diff --git a/client/src/components/ImgManagement/ImgManagement.tsx b/client/src/components/ImgManagement/ImgManagement.tsx index a26d0d1..89d8071 100644 --- a/client/src/components/ImgManagement/ImgManagement.tsx +++ b/client/src/components/ImgManagement/ImgManagement.tsx @@ -8,7 +8,7 @@ import { useSelector } from 'react-redux'; import { getImageUrl } from '@/components/Editor/configs/uploadConfig'; import { Icon } from '@/components/Icon/Icon'; -import { ImgListItem, useDeleteWorkspaceImgMutation, useGetImgListQuery } from '@/redux-api/imgStoreApi'; +import { ImgListItem, useDeleteWorkspaceImgMutation, useGetImgListQuery } from '@/redux-api/img'; import { selectCurContent } from '@/redux-feature/curDocSlice'; import Toast from '@/utils/Toast'; import { confirm } from '@/utils/utils'; 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/redux-api/imgStoreApi.ts b/client/src/redux-api/img.ts similarity index 56% rename from client/src/redux-api/imgStoreApi.ts rename to client/src/redux-api/img.ts index 830a1bd..194ea20 100644 --- a/client/src/redux-api/imgStoreApi.ts +++ b/client/src/redux-api/img.ts @@ -57,43 +57,6 @@ 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'], - }), }), /* @@ -107,11 +70,4 @@ const imgApi = docsApi.injectEndpoints({ overrideExisting: false, }); -export const { - useGetImgListQuery, - useDeleteWorkspaceImgMutation, - useGetUploadHistoryQuery, - useUploadImgMutation, - useDeleteImgMutation, - useRenameImgMutation, -} = imgApi; +export const { useGetImgListQuery, useDeleteWorkspaceImgMutation } = imgApi; From b2047e3bfbcc851d18065ee82d8122e51887a100 Mon Sep 17 00:00:00 2001 From: s-elo Date: Thu, 5 Mar 2026 20:49:38 +0800 Subject: [PATCH 14/20] feat: separate editor settings and worksapce settings --- crates/server/src/services/settings.rs | 96 ++++++++++++++++++++++---- 1 file changed, 84 insertions(+), 12 deletions(-) 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) } From 610fd8efd7fb35f54daca0e624f2e331a9c74933 Mon Sep 17 00:00:00 2001 From: s-elo Date: Fri, 6 Mar 2026 19:11:15 +0800 Subject: [PATCH 15/20] feat: add get_img_ref_docs api --- crates/server/src/handlers/img.rs | 24 ++++++++++++-- crates/server/src/routes/img.rs | 4 ++- crates/server/src/services/img.rs | 54 ++++++++++++++++++++++++++----- crates/server/src/state/app.rs | 5 ++- 4 files changed, 74 insertions(+), 13 deletions(-) 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/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/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/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, From 355c6dc600b243c1424e54c7f57ec7dec197ea80 Mon Sep 17 00:00:00 2001 From: s-elo Date: Mon, 9 Mar 2026 16:08:12 +0800 Subject: [PATCH 16/20] fix: git deleted files changes --- client/src/components/GitBox/GitBox.scss | 5 +++-- crates/server/src/services/git.rs | 12 ++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) 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/crates/server/src/services/git.rs b/crates/server/src/services/git.rs index e5a2cea..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()?; From 653812aa69cd60fa52f8dabe5e92918f06187270 Mon Sep 17 00:00:00 2001 From: s-elo Date: Tue, 10 Mar 2026 20:36:21 +0800 Subject: [PATCH 17/20] fix: commit msg box input value updates --- client/src/components/GitBox/GitBox.tsx | 73 ++++++++++++++--------- client/src/components/Sidebar/Sidebar.tsx | 2 +- 2 files changed, 46 insertions(+), 29 deletions(-) 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/Sidebar/Sidebar.tsx b/client/src/components/Sidebar/Sidebar.tsx index 365c89a..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'; From 4d6bcb602a9db1f967bdd9e4abcec2f645c23864 Mon Sep 17 00:00:00 2001 From: s-elo Date: Wed, 11 Mar 2026 16:27:06 +0800 Subject: [PATCH 18/20] chore: polish copyCut confirmation message --- client/src/components/Menu/operations.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/client/src/components/Menu/operations.tsx b/client/src/components/Menu/operations.tsx index 1667c70..05487c9 100644 --- a/client/src/components/Menu/operations.tsx +++ b/client/src/components/Menu/operations.tsx @@ -342,14 +342,17 @@ 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?.(); From 37477b1195c8dfbbd9a6db1c0abe5faa6b4f7e86 Mon Sep 17 00:00:00 2001 From: s-elo Date: Fri, 20 Mar 2026 15:10:10 +0800 Subject: [PATCH 19/20] chore: upgrade milkdown packages to 7.19.0 --- client/package.json | 10 +- pnpm-lock.yaml | 294 ++++++++++++++++++++++---------------------- 2 files changed, 149 insertions(+), 155 deletions(-) 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/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: From 0f5d3849c8e552ac8fef1302ab492d1c61b790dc Mon Sep 17 00:00:00 2001 From: s-elo Date: Fri, 20 Mar 2026 15:42:59 +0800 Subject: [PATCH 20/20] feat: support image reference UI and image preview --- .../ImgManagement/ImgManagement.scss | 33 +++++++ .../ImgManagement/ImgManagement.tsx | 85 ++++++++++++++++++- client/src/redux-api/img.ts | 12 ++- 3 files changed, 125 insertions(+), 5 deletions(-) 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 89d8071..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/img'; +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/redux-api/img.ts b/client/src/redux-api/img.ts index 194ea20..81e71d3 100644 --- a/client/src/redux-api/img.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,6 +62,11 @@ const imgApi = docsApi.injectEndpoints({ transformResponse, transformErrorResponse, }), + getImgRefDocs: builder.query({ + query: (fileName) => `/imgs/ref-docs?fileName=${encodeURIComponent(fileName)}`, + transformResponse, + transformErrorResponse, + }), }), /* @@ -70,4 +80,4 @@ const imgApi = docsApi.injectEndpoints({ overrideExisting: false, }); -export const { useGetImgListQuery, useDeleteWorkspaceImgMutation } = imgApi; +export const { useGetImgListQuery, useDeleteWorkspaceImgMutation, useLazyGetImgRefDocsQuery } = imgApi;