feat: initial commit - terminal note app with SQLite

This commit is contained in:
2026-04-11 01:24:17 +08:00
parent 3e05fd06fe
commit 6e2c50ca30
9 changed files with 653 additions and 11 deletions

6
.gitignore vendored
View File

@@ -31,4 +31,8 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
.idea
# Finder (MacOS) folder config
.DS_Store
.DS_Store
# Project
*.exe
mio*

View File

@@ -5,8 +5,10 @@
"": {
"name": "react",
"dependencies": {
"@libsql/client": "^0.17.2",
"@opentui/core": "^0.1.97",
"@opentui/react": "^0.1.97",
"marked": "^18.0.0",
"react": "^19.2.4",
},
"devDependencies": {
@@ -76,6 +78,34 @@
"@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="],
"@libsql/client": ["@libsql/client@0.17.2", "", { "dependencies": { "@libsql/core": "^0.17.2", "@libsql/hrana-client": "^0.9.0", "js-base64": "^3.7.5", "libsql": "^0.5.28", "promise-limit": "^2.7.0" } }, "sha512-0aw0S3iQMHvOxfRt5j1atoCCPMT3gjsB2PS8/uxSM1DcDn39xqz6RlgSMxtP8I3JsxIXAFuw7S41baLEw0Zi+Q=="],
"@libsql/core": ["@libsql/core@0.17.2", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-L8qv12HZ/jRBcETVR3rscP0uHNxh+K3EABSde6scCw7zfOdiLqO3MAkJaeE1WovPsjXzsN/JBoZED4+7EZVT3g=="],
"@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.29", "", { "os": "darwin", "cpu": "arm64" }, "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A=="],
"@libsql/darwin-x64": ["@libsql/darwin-x64@0.5.29", "", { "os": "darwin", "cpu": "x64" }, "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ=="],
"@libsql/hrana-client": ["@libsql/hrana-client@0.9.0", "", { "dependencies": { "@libsql/isomorphic-ws": "^0.1.5", "cross-fetch": "^4.0.0", "js-base64": "^3.7.5", "node-fetch": "^3.3.2" } }, "sha512-pxQ1986AuWfPX4oXzBvLwBnfgKDE5OMhAdR/5cZmRaB4Ygz5MecQybvwZupnRz341r2CtFmbk/BhSu7k2Lm+Jw=="],
"@libsql/isomorphic-ws": ["@libsql/isomorphic-ws@0.1.5", "", { "dependencies": { "@types/ws": "^8.5.4", "ws": "^8.13.0" } }, "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg=="],
"@libsql/linux-arm-gnueabihf": ["@libsql/linux-arm-gnueabihf@0.5.29", "", { "os": "linux", "cpu": "arm" }, "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ=="],
"@libsql/linux-arm-musleabihf": ["@libsql/linux-arm-musleabihf@0.5.29", "", { "os": "linux", "cpu": "arm" }, "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg=="],
"@libsql/linux-arm64-gnu": ["@libsql/linux-arm64-gnu@0.5.29", "", { "os": "linux", "cpu": "arm64" }, "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w=="],
"@libsql/linux-arm64-musl": ["@libsql/linux-arm64-musl@0.5.29", "", { "os": "linux", "cpu": "arm64" }, "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg=="],
"@libsql/linux-x64-gnu": ["@libsql/linux-x64-gnu@0.5.29", "", { "os": "linux", "cpu": "x64" }, "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg=="],
"@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.5.29", "", { "os": "linux", "cpu": "x64" }, "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w=="],
"@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.5.29", "", { "os": "win32", "cpu": "x64" }, "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg=="],
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
"@opentui/core": ["@opentui/core@0.1.97", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.97", "@opentui/core-darwin-x64": "0.1.97", "@opentui/core-linux-arm64": "0.1.97", "@opentui/core-linux-x64": "0.1.97", "@opentui/core-win32-arm64": "0.1.97", "@opentui/core-win32-x64": "0.1.97", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-2ENH0Dc4NUAeHeeQCQhF1lg68RuyntOUP68UvortvDqTz/hqLG0tIwF+DboCKtWi8Nmao4SAQEJ7lfmyQNEDOQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.97", "", { "os": "darwin", "cpu": "arm64" }, "sha512-t7oMGEfMPQsqLEx7/rPqv/UGJ+vqhe4RWHRRQRYcuHuLKssZ2S8P9mSS7MBPtDqGcxg4PosCrh5nHYeZ94EXUw=="],
@@ -100,6 +130,8 @@
"@types/react": ["@types/react@19.1.11", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@webgpu/types": ["@webgpu/types@0.1.64", "", {}, "sha512-84kRIAGV46LJTlJZWxShiOrNL30A+9KokD7RB3dRCIqODFjodS5tCD5yyiZ8kIReGVZSDfA3XkkwyyOIF6K62A=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
@@ -128,8 +160,14 @@
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-zvnUl4EAsQbKsmZVu+lEJcH8axQ7MiCfqg2OmnHd6uw1THABmHaX0GbpKiHshdgadNN2Nf+4zDyTJB5YMcAdrA=="],
"cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
@@ -138,8 +176,12 @@
"exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -150,10 +192,18 @@
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
"libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="],
"marked": ["marked@18.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA=="],
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
@@ -174,6 +224,8 @@
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
"promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
@@ -206,14 +258,22 @@
"token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"web-tree-sitter": ["web-tree-sitter@0.26.0", "", {}, "sha512-wGGAMnJEMF8wy33iEGxSvnyEOfVLzSaa3x6g66aEHsL/hsgFb6IVPrpacIordAMz198pE9qReCEqFUuM0pnfwg=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="],
@@ -226,6 +286,10 @@
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="],
"pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],

34
changelog.md Normal file
View File

@@ -0,0 +1,34 @@
# 更新日志
## v0.1.0 (2026-04-11)
### 新增功能
- **双列布局**: 左侧笔记列表,右侧编辑器/预览
- **笔记管理**: 创建、编辑、删除笔记
- **Markdown 预览**: 手动实现的 Markdown 渲染器,支持:
- 标题 (# ## ###)
- 列表 (- *)
- 引用 (>)
- 代码块 (```)
- 分割线 (---)
- **多选删除**: 双击进入选择模式,点击切换选中状态,支持批量删除
- **标题截断**: 左侧列表标题过长时显示省略号
- **时间显示**: 格式为 "时间 · 月/日/年",如 "12:45 · 4/11/2026"
### 技术改进
- SQLite 数据库存储位置:`~/.config/mio/cache.db`
- 可编译为单个可执行文件 (mio.exe)
### 已知问题
- 多选功能使用双击模式,不如键盘快捷键直观
- TypeScript 类型警告bold 属性等)
### 构建
```bash
npm run dev # 开发模式
npm run build # 编译成 mio.exe
```

65
docs/taolun.md Normal file
View File

@@ -0,0 +1,65 @@
# 项目讨论记录
## 需求确认
1. **双列布局**: 左侧笔记列表,右侧编辑器/预览
2. **SQLite 存储**: 使用 libsql 客户端
3. **Markdown 支持**: 渲染 Markdown 内容
4. **类似 Glow 效果**: 终端 Markdown 渲染
## 技术问题与解决方案
### OpenTUI Markdown 组件问题
**问题**: OpenTUI 的 `<markdown>` 组件无法正确渲染样式(尝试多种方式均失败)
**解决方案**: 实现手动 Markdown 解析器,用 OpenTUI 的 `<text>` 组件手动渲染样式
### Textarea 换行问题
**问题**: textarea 默认 wrapMode 在 React 类型中缺失
**解决方案**: 通过设置 `focused` 属性让 textarea 可用,使用 EditBufferRenderable 的 wrapMode 选项(需要通过底层访问)
### 预览模式换行问题
**问题**: 编辑模式下输入的换行在预览时不显示
**解决方案**: 在 `renderMarkdown` 函数中处理空行,添加 `<text>{" "}</text>`
### 代码块渲染
**问题**: ``` 代码块里的缩进渲染有问题
**解决方案**: 实现代码块检测和渲染逻辑,每行代码单独渲染
### 多选功能
**问题**: OpenTUI 的 `onMouseDown` 事件不包含 ctrlKey/shiftKey 修饰键信息,且 `useKeyboard` 无法捕获修饰键
**解决方案**:
- 尝试使用 `useKeyboard` 监听修饰键但失败(终端可能不发送单独的修饰键事件)
- 改用双击进入"选择模式"的方案:双击任意笔记切换选择模式,在选择模式下点击可多选
### 数据库跨平台路径
**问题**: 硬编码的数据库路径在不同平台不兼容
**解决方案**: 实现 `getDbPath()` 函数,根据 `os.platform()` 判断系统类型
- Windows: `%APPDATA%/mio/cache.db`
- Mac: `~/Library/Application Support/mio/cache.db`
- Linux: `~/.config/mio/cache.db`
## 待解决问题
1. **多选功能**: 双击选择模式的体验不够理想,键盘修饰键方案未能实现
2. **TypeScript 类型错误**: NoteEditor 中存在一些类型错误bold 属性不存在等)
## 构建说明
```bash
npm run dev # 开发模式
npm run build # 编译成 mio.exe
```
数据库位置:`~/.config/mio/cache.db`(所有平台统一)

View File

@@ -1,10 +1,11 @@
{
"name": "note",
"name": "mio",
"module": "src/index.tsx",
"type": "module",
"private": true,
"scripts": {
"dev": "bun run --watch src/index.tsx"
"dev": "bun run --watch src/index.tsx",
"build": "bun build src/index.tsx --compile --outfile mio.exe"
},
"devDependencies": {
"@types/bun": "latest"
@@ -13,8 +14,10 @@
"typescript": "^5"
},
"dependencies": {
"@libsql/client": "^0.17.2",
"@opentui/core": "^0.1.97",
"@opentui/react": "^0.1.97",
"marked": "^18.0.0",
"react": "^19.2.4"
}
}

View File

@@ -0,0 +1,171 @@
import { useState, useEffect, useRef } from "react";
import { db, type Note } from "../db";
interface NoteEditorProps {
note: Note | null;
onUpdate: () => void;
onDelete: (id: number) => void;
}
function renderMarkdown(content: string): any {
const lines = content.split('\n');
const elements: any[] = [];
let inCodeBlock = false;
const codeLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim().startsWith('```')) {
if (!inCodeBlock) {
inCodeBlock = true;
codeLines.length = 0;
} else {
for (let j = 0; j < codeLines.length; j++) {
elements.push(<text bg="#1f2335" fg="#9ece6a">{codeLines[j]}</text>);
}
inCodeBlock = false;
}
continue;
}
if (inCodeBlock) {
codeLines.push(line);
continue;
}
const trimmed = line.trim();
if (trimmed.startsWith('# ')) {
elements.push(<text fg="#7aa2f7" bold>{trimmed.substring(2)}</text>);
} else if (trimmed.startsWith('## ')) {
elements.push(<text fg="#bb9af7" bold>{trimmed.substring(3)}</text>);
} else if (trimmed.startsWith('### ')) {
elements.push(<text fg="#9ece6a" bold>{trimmed.substring(4)}</text>);
} else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
elements.push(<text fg="#ff7b72"> {trimmed.substring(2)}</text>);
} else if (trimmed.startsWith('> ')) {
elements.push(<text fg="#565f89"> {trimmed.substring(2)}</text>);
} else if (trimmed === '---' || trimmed === '***' || trimmed === '___') {
elements.push(<text fg="#414868"></text>);
} else if (!trimmed) {
elements.push(<text>{" "}</text>);
} else {
elements.push(<text>{trimmed}</text>);
}
}
return elements;
}
export function NoteEditor({ note, onUpdate, onDelete }: NoteEditorProps) {
const [isEditing, setIsEditing] = useState(false);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const textareaRef = useRef<{ plainText: string } | null>(null);
useEffect(() => {
if (note) {
setTitle(note.title);
setContent(note.content);
} else {
setTitle("");
setContent("");
}
setIsEditing(false);
}, [note?.id]);
const handleSave = async () => {
if (note) {
const finalContent = textareaRef.current?.plainText ?? content;
await db.updateNote(note.id, title, finalContent);
setContent(finalContent);
onUpdate();
setIsEditing(false);
}
};
if (!note) {
return (
<box flexGrow={1} justifyContent="center" alignItems="center">
<text fg="#565f89">Select a note or create a new one</text>
</box>
);
}
const displayContent = content || "* No content yet. Click Edit to start writing. *";
// ---------------------------------------------------
// 这下面的组件ai只读嵌套层级不许更改
return (
<box flexDirection="column" flexGrow={1}>
<box
paddingX={2}
paddingY={1}
backgroundColor="#1f2335"
flexDirection="row"
alignItems="center"
justifyContent="space-between"
width="100%"
>
{isEditing ? (
<box width="90%">
<input
value={title}
onChange={setTitle}
backgroundColor="transparent"
textColor="#e0af68"
width="100%"
/>
</box>
) : (
<box>
<text fg="#e0af68">
<strong>{title || "Untitled"}</strong>
</text>
</box>
)}
<box flexDirection="row" gap={1} height={"100%"}>
<box backgroundColor={isEditing ? "#9ece6a" : "#7aa2f7"}>
<text onMouseDown={() => isEditing ? handleSave() : setIsEditing(true)}>
{isEditing ? "[保存]" : "[编辑]"}
</text>
</box>
<box backgroundColor="#f7768e">
<text onMouseDown={() => note && onDelete(note.id)}>
[]
</text>
</box>
</box>
</box>
{/* // 这上面的组件ai只读嵌套层级不许更改 */}
<box flexGrow={1} overflow="hidden" backgroundColor="#1a1b2600">
{isEditing ? (
/* // 这下面的组件ai只读嵌套层级不许更改 */
<box padding={2}>
<textarea
initialValue={content}
backgroundColor="#1a1b2600"
textColor="#c0caf5"
placeholderColor="#565f89"
placeholder="Write in Markdown..."
width="100%"
height="100%"
focused
ref={(el) => { textareaRef.current = el as unknown as { plainText: string } | null; }}
/>
</box>
// 这上面的组件ai只读嵌套层级不许更改
) : (
<scrollbox focused stickyScroll height={"100%"}>
<box flexDirection="column" padding={2}>
{renderMarkdown(displayContent)}
</box>
</scrollbox>
)}
</box>
</box>
);
}

123
src/components/NoteList.tsx Normal file
View File

@@ -0,0 +1,123 @@
import { useState } from "react";
import type { Note } from "../db";
interface NoteListProps {
notes: Note[];
selectedId: number | null;
selectedIds: Set<number>;
onSelect: (id: number) => void;
onMultiSelect: (ids: number[]) => void;
onToggleSelect: (id: number) => void;
onCreate: () => void;
onDeleteSelected: () => void;
}
export function NoteList({ notes, selectedId, selectedIds, onSelect, onMultiSelect, onToggleSelect, onCreate, onDeleteSelected }: NoteListProps) {
const [lastSelectedId, setLastSelectedId] = useState<number | null>(null);
const [selectionMode, setSelectionMode] = useState(false);
const handleClick = (id: number) => {
if (selectionMode) {
onToggleSelect(id);
} else if (selectedIds.size > 0 && lastSelectedId !== null) {
const startIdx = notes.findIndex(n => n.id === lastSelectedId);
const endIdx = notes.findIndex(n => n.id === id);
const [from, to] = startIdx < endIdx ? [startIdx, endIdx] : [endIdx, startIdx];
const rangeIds = notes.slice(from, to + 1).map(n => n.id);
onMultiSelect(rangeIds);
} else {
onSelect(id);
}
setLastSelectedId(id);
};
const handleDoubleClick = (id: number) => {
setSelectionMode(!selectionMode);
};
const hasMultipleSelected = selectedIds.size > 1;
return (
<box flexDirection="column" flexGrow={1} paddingX={1}>
<box flexDirection="row" alignItems="center" marginBottom={1}>
<box
paddingX={2}
paddingY={1}
backgroundColor="#1f2335"
onMouseDown={() => onCreate()}
>
<text>
<strong fg="#5381e48e">+ </strong>
</text>
</box>
{hasMultipleSelected && (
<box paddingX={2} paddingY={1} backgroundColor="#f7768e" marginLeft={1} onMouseDown={() => onDeleteSelected()}>
<text>
({selectedIds.size})
</text>
</box>
)}
</box>
<box flexDirection="column" flexGrow={1} overflow="scroll">
<scrollbox focused stickyScroll height={"100%"}>
{notes.map((note) => (
<NoteItem
key={note.id}
note={note}
isSelected={note.id === selectedId}
isMultiSelected={selectedIds.has(note.id)}
selectionMode={selectionMode}
onClick={() => handleClick(note.id)}
onDoubleClick={() => handleDoubleClick(note.id)}
/>
))}
</scrollbox>
</box>
</box>
);
}
interface NoteItemProps {
note: Note;
isSelected: boolean;
isMultiSelected: boolean;
selectionMode: boolean;
onClick: () => void;
onDoubleClick: () => void;
}
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + "...";
}
function NoteItem({ note, isSelected, isMultiSelected, selectionMode, onClick, onDoubleClick }: NoteItemProps) {
const isActive = isSelected || isMultiSelected;
const bgColor = isActive ? "#414868" : "transparent";
const fgColor = isActive ? "#c0caf5" : "#a9b1d6";
const date = new Date(note.updatedAt);
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const dateStr = `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`;
const displayTitle = truncateText(note.title || "Untitled", 10);
return (
<box
paddingX={2}
paddingY={1}
backgroundColor={bgColor}
onMouseDown={() => onClick()}
onMouseDownCapture={() => onDoubleClick()}
width={"100%"}
flexDirection="column"
>
<box flexGrow={1} width={"100%"} >
<text fg={fgColor}>
<strong>{displayTitle}</strong>
</text>
</box>
<box flexGrow={1} width={"100%"} paddingTop={1}>
<text fg="#565f89"> {timeStr} · {dateStr}</text>
</box>
</box>
);
}

86
src/db.ts Normal file
View File

@@ -0,0 +1,86 @@
import { createClient } from "@libsql/client";
import * as os from "node:os";
import * as path from "node:path";
import * as fs from "node:fs";
function getDbPath(): string {
const homeDir = os.homedir();
const configDir = path.join(homeDir, ".config", "mio");
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
return path.join(configDir, "cache.db");
}
export interface Note {
id: number;
title: string;
content: string;
createdAt: string;
updatedAt: string;
}
class Database {
private client: ReturnType<typeof createClient>;
constructor() {
const dbPath = getDbPath();
this.client = createClient({
url: `file:${dbPath}`,
});
this.init();
}
private async init() {
await this.client.execute(`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT 'Untitled',
content TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
}
async getAllNotes(): Promise<Note[]> {
const result = await this.client.execute(
"SELECT id, title, content, created_at as createdAt, updated_at as updatedAt FROM notes ORDER BY updated_at DESC"
);
return result.rows as unknown as Note[];
}
async getNote(id: number): Promise<Note | null> {
const result = await this.client.execute({
sql: "SELECT id, title, content, created_at as createdAt, updated_at as updatedAt FROM notes WHERE id = ?",
args: [id],
});
return (result.rows[0] as unknown as Note) || null;
}
async createNote(title: string = "Untitled", content: string = ""): Promise<Note> {
const result = await this.client.execute({
sql: "INSERT INTO notes (title, content) VALUES (?, ?) RETURNING id, title, content, created_at as createdAt, updated_at as updatedAt",
args: [title, content],
});
return result.rows[0] as unknown as Note;
}
async updateNote(id: number, title: string, content: string): Promise<void> {
await this.client.execute({
sql: "UPDATE notes SET title = ?, content = ?, updated_at = datetime('now') WHERE id = ?",
args: [title, content, id],
});
}
async deleteNote(id: number): Promise<void> {
await this.client.execute({
sql: "DELETE FROM notes WHERE id = ?",
args: [id],
});
}
}
export const db = new Database();

View File

@@ -1,16 +1,108 @@
import { createCliRenderer, TextAttributes } from "@opentui/core";
import { createRoot } from "@opentui/react";
import { useState, useEffect } from "react";
import { createCliRenderer } from "@opentui/core";
import { createRoot, useKeyboard } from "@opentui/react";
import { db, type Note } from "./db";
import { NoteList } from "./components/NoteList";
import { NoteEditor } from "./components/NoteEditor";
function App() {
const [notes, setNotes] = useState<Note[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
loadNotes();
}, [refreshKey]);
const loadNotes = async () => {
const allNotes = await db.getAllNotes();
setNotes(allNotes);
};
const handleSelect = (id: number) => {
setSelectedId(id);
setSelectedIds(new Set([id]));
};
const handleMultiSelect = (ids: number[]) => {
setSelectedIds(new Set(ids));
const lastId = ids[ids.length - 1];
if (lastId !== undefined) {
setSelectedId(lastId);
}
};
const handleToggleSelect = (id: number) => {
const newSelected = new Set(selectedIds);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
setSelectedIds(newSelected);
if (newSelected.size > 0) {
setSelectedId(id);
}
};
const handleCreate = async () => {
const newNote = await db.createNote();
setSelectedId(newNote.id);
setSelectedIds(new Set([newNote.id]));
setRefreshKey((k) => k + 1);
};
const handleUpdate = () => {
setRefreshKey((k) => k + 1);
};
const handleDelete = async (id: number) => {
await db.deleteNote(id);
if (selectedId === id) {
setSelectedId(null);
setSelectedIds(new Set());
}
setRefreshKey((k) => k + 1);
};
const handleDeleteSelected = async () => {
for (const id of selectedIds) {
await db.deleteNote(id);
}
setSelectedId(null);
setSelectedIds(new Set());
setRefreshKey((k) => k + 1);
};
const selectedNote = selectedId ? notes.find((n) => n.id === selectedId) : null;
useKeyboard((key) => {
if (key.name === "escape") {
process.exit(0);
}
});
return (
<box alignItems="center" justifyContent="center" flexGrow={1}>
<box justifyContent="center" alignItems="flex-end">
<ascii-font font="tiny" text="OpenTUI" />
<text attributes={TextAttributes.DIM}>What will you build?</text>
<box flexDirection="row" flexGrow={1} padding={1} backgroundColor="#1a1b2600">
<box width={35} marginRight={1}>
<NoteList
notes={notes}
selectedId={selectedId}
selectedIds={selectedIds}
onSelect={handleSelect}
onMultiSelect={handleMultiSelect}
onToggleSelect={handleToggleSelect}
onCreate={handleCreate}
onDeleteSelected={handleDeleteSelected}
/>
</box>
<box flexGrow={1}>
<NoteEditor note={selectedNote || null} onUpdate={handleUpdate} onDelete={handleDelete} />
</box>
</box>
);
}
const renderer = await createCliRenderer();
createRoot(renderer).render(<App />);
createRoot(renderer).render(<App />);