mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-03 02:11:07 +05:30
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccb998be66 | ||
|
|
813582aa8d | ||
|
|
b1e16af62d | ||
|
|
487ce2ad45 | ||
|
|
1717b2e8c0 | ||
|
|
cdd370c62d | ||
|
|
6df21ace35 | ||
|
|
c93747c476 | ||
|
|
d865b181e9 | ||
|
|
5e5bab3da9 | ||
|
|
ce2bf62d42 | ||
|
|
6da98c3a54 | ||
|
|
3598d06240 | ||
|
|
650c266945 | ||
|
|
05bbb9f86b | ||
|
|
77c8e05fa5 | ||
|
|
17f318dd75 | ||
|
|
fa5a46d5ac |
Generated
+801
-1
@@ -27,6 +27,9 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
@@ -1851,6 +1854,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@floating-ui/core": {
|
||||||
|
"version": "1.7.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||||
|
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/utils": "^0.2.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/dom": {
|
||||||
|
"version": "1.7.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||||
|
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/core": "^1.7.5",
|
||||||
|
"@floating-ui/utils": "^0.2.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/react-dom": {
|
||||||
|
"version": "2.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
|
||||||
|
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "^1.7.6"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/utils": {
|
||||||
|
"version": "0.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||||
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@hookform/resolvers": {
|
"node_modules/@hookform/resolvers": {
|
||||||
"version": "5.4.0",
|
"version": "5.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz",
|
||||||
@@ -3805,6 +3846,115 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/number": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-arrow": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-collapsible": {
|
||||||
|
"version": "1.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
|
||||||
|
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-collection": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-compose-refs": {
|
"node_modules/@radix-ui/react-compose-refs": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||||
@@ -3820,6 +3970,390 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-context-menu": {
|
||||||
|
"version": "2.2.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz",
|
||||||
|
"integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-menu": "2.1.16",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-direction": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||||
|
"version": "1.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
|
||||||
|
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-focus-guards": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-focus-scope": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-id": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu": {
|
||||||
|
"version": "2.1.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
|
||||||
|
"integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-collection": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
|
"@radix-ui/react-focus-guards": "1.1.3",
|
||||||
|
"@radix-ui/react-focus-scope": "1.1.7",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-popper": "1.2.8",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-roving-focus": "1.1.11",
|
||||||
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"aria-hidden": "^1.2.4",
|
||||||
|
"react-remove-scroll": "^2.6.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-popper": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/react-dom": "^2.0.0",
|
||||||
|
"@radix-ui/react-arrow": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||||
|
"@radix-ui/react-use-rect": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1",
|
||||||
|
"@radix-ui/rect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-portal": {
|
||||||
|
"version": "1.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||||
|
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-presence": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-roving-focus": {
|
||||||
|
"version": "1.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
||||||
|
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-collection": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area": {
|
||||||
|
"version": "1.2.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
|
||||||
|
"integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/number": "1.1.1",
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-slot": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||||
@@ -3838,6 +4372,133 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-effect-event": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-escape-keydown": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-rect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/rect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-size": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/rect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||||
@@ -4849,7 +5510,7 @@
|
|||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
@@ -5816,6 +6477,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/aria-hidden": {
|
||||||
|
"version": "1.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||||
|
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/aria-query": {
|
"node_modules/aria-query": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||||
@@ -7976,6 +8649,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-node-es": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dfa": {
|
"node_modules/dfa": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||||
@@ -9515,6 +10194,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-nonce": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-package-type": {
|
"node_modules/get-package-type": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
|
||||||
@@ -14227,6 +14915,53 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-remove-scroll": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-remove-scroll-bar": "^2.3.7",
|
||||||
|
"react-style-singleton": "^2.2.3",
|
||||||
|
"tslib": "^2.1.0",
|
||||||
|
"use-callback-ref": "^1.3.3",
|
||||||
|
"use-sidecar": "^1.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-remove-scroll-bar": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-style-singleton": "^2.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-resizable-panels": {
|
"node_modules/react-resizable-panels": {
|
||||||
"version": "4.11.2",
|
"version": "4.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.11.2.tgz",
|
||||||
@@ -14237,6 +14972,28 @@
|
|||||||
"react-dom": "^18.0.0 || ^19.0.0"
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-style-singleton": {
|
||||||
|
"version": "2.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
|
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"get-nonce": "^1.0.0",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-binary-file-arch": {
|
"node_modules/read-binary-file-arch": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
||||||
@@ -15986,6 +16743,49 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-callback-ref": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/use-sidecar": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-node-es": "^1.1.0",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/utf8-byte-length": {
|
"node_modules/utf8-byte-length": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
|
||||||
|
|||||||
@@ -98,6 +98,9 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
|
|||||||
+49
@@ -3039,6 +3039,55 @@ ipcMain.on('select-folder', (event, type) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// List directory contents as FileEntry[]
|
||||||
|
ipcMain.handle('list-directory', async (event, dirPath) => {
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||||
|
return entries.map((dirent) => {
|
||||||
|
const fullPath = path.join(dirPath, dirent.name);
|
||||||
|
let size;
|
||||||
|
let modifiedAt;
|
||||||
|
if (!dirent.isDirectory()) {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
size = stat.size;
|
||||||
|
modifiedAt = stat.mtime.toISOString();
|
||||||
|
} catch {
|
||||||
|
// ignore stat errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: dirent.name,
|
||||||
|
path: fullPath,
|
||||||
|
isDirectory: dirent.isDirectory(),
|
||||||
|
size,
|
||||||
|
modifiedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show folder picker dialog and return selected path or null
|
||||||
|
ipcMain.handle('pick-folder', async () => {
|
||||||
|
const result = await dialog.showOpenDialog(mainWindow, {
|
||||||
|
properties: ['openDirectory'],
|
||||||
|
});
|
||||||
|
if (result.canceled || result.filePaths.length === 0) return null;
|
||||||
|
return result.filePaths[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show file picker dialog for markdown files and return selected path or null
|
||||||
|
ipcMain.handle('pick-file', async () => {
|
||||||
|
const result = await dialog.showOpenDialog(mainWindow, {
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd'] }],
|
||||||
|
});
|
||||||
|
if (result.canceled || result.filePaths.length === 0) return null;
|
||||||
|
return result.filePaths[0];
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.on('export-spreadsheet', (event, { content, format }) => {
|
ipcMain.on('export-spreadsheet', (event, { content, format }) => {
|
||||||
const outputFile = dialog.showSaveDialogSync(mainWindow, {
|
const outputFile = dialog.showSaveDialogSync(mainWindow, {
|
||||||
defaultPath: currentFile.replace(/\.[^/.]+$/, `.${format}`),
|
defaultPath: currentFile.replace(/\.[^/.]+$/, `.${format}`),
|
||||||
|
|||||||
+10
-2
@@ -237,7 +237,12 @@ const ALLOWED_RECEIVE_CHANNELS = [
|
|||||||
'load-template-menu',
|
'load-template-menu',
|
||||||
'toggle-command-palette',
|
'toggle-command-palette',
|
||||||
'toggle-sidebar-panel',
|
'toggle-sidebar-panel',
|
||||||
'toggle-bottom-panel'
|
'toggle-bottom-panel',
|
||||||
|
|
||||||
|
// File dialog / directory listing
|
||||||
|
'list-directory',
|
||||||
|
'pick-folder',
|
||||||
|
'pick-file'
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -344,7 +349,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
|
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
|
||||||
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
|
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
|
||||||
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
|
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
|
||||||
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination })
|
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination }),
|
||||||
|
list: (dirPath) => ipcRenderer.invoke('list-directory', dirPath),
|
||||||
|
pickFolder: () => ipcRenderer.invoke('pick-folder'),
|
||||||
|
pickFile: () => ipcRenderer.invoke('pick-file')
|
||||||
},
|
},
|
||||||
|
|
||||||
// Theme Operations
|
// Theme Operations
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { PanelLeft, PanelRight } from 'lucide-react';
|
import { PanelLeft, PanelRight, Keyboard } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ThemeToggle } from '@/components/theme-toggle';
|
import { ThemeToggle } from '@/components/theme-toggle';
|
||||||
import { useAppStore } from '@/stores/app-store';
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
|
|
||||||
export function AppHeader() {
|
export function AppHeader() {
|
||||||
const { sidebarVisible, previewVisible, toggleSidebar, togglePreview } = useAppStore();
|
const { sidebarVisible, previewVisible } = useAppStore();
|
||||||
|
const dispatch = useCommandStore((s) => s.dispatch);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-14 items-center justify-between border-b border-border bg-card/40 px-4 backdrop-blur">
|
<header className="flex h-14 items-center justify-between border-b border-border bg-card/40 px-4 backdrop-blur">
|
||||||
@@ -21,7 +23,8 @@ export function AppHeader() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
aria-label="Toggle sidebar"
|
aria-label="Toggle sidebar"
|
||||||
aria-pressed={sidebarVisible}
|
aria-pressed={sidebarVisible}
|
||||||
onClick={toggleSidebar}
|
data-testid="header-toggle-sidebar"
|
||||||
|
onClick={() => dispatch('view.toggleSidebar')}
|
||||||
>
|
>
|
||||||
<PanelLeft className="h-4 w-4" />
|
<PanelLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -30,12 +33,22 @@ export function AppHeader() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
aria-label="Toggle preview"
|
aria-label="Toggle preview"
|
||||||
aria-pressed={previewVisible}
|
aria-pressed={previewVisible}
|
||||||
onClick={togglePreview}
|
data-testid="header-toggle-preview"
|
||||||
|
onClick={() => dispatch('view.togglePreview')}
|
||||||
>
|
>
|
||||||
<PanelRight className="h-4 w-4" />
|
<PanelRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Keyboard shortcuts"
|
||||||
|
data-testid="header-shortcuts"
|
||||||
|
onClick={() => dispatch('shortcuts.show')}
|
||||||
|
>
|
||||||
|
<Keyboard className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,18 @@ import { Breadcrumb } from './Breadcrumb';
|
|||||||
import { StatusBar } from './StatusBar';
|
import { StatusBar } from './StatusBar';
|
||||||
import { EditorPane } from '@/components/editor/EditorPane';
|
import { EditorPane } from '@/components/editor/EditorPane';
|
||||||
import { PreviewPane } from '@/components/preview/PreviewPane';
|
import { PreviewPane } from '@/components/preview/PreviewPane';
|
||||||
|
import { Sidebar } from '@/components/sidebar/Sidebar';
|
||||||
import { useAppStore } from '@/stores/app-store';
|
import { useAppStore } from '@/stores/app-store';
|
||||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||||||
|
import { useFileShortcuts } from '@/hooks/use-file-shortcuts';
|
||||||
|
import { useRestoreLastFolder } from '@/hooks/use-restore-last-folder';
|
||||||
|
import { useRegisterMenuCommands, useBridgeNativeMenu } from '@/lib/commands/register-menu-commands';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
|
useFileShortcuts();
|
||||||
|
useRestoreLastFolder();
|
||||||
|
useRegisterMenuCommands();
|
||||||
|
useBridgeNativeMenu();
|
||||||
const { sidebarVisible, previewVisible, paneSizes, setPaneSizes } = useAppStore();
|
const { sidebarVisible, previewVisible, paneSizes, setPaneSizes } = useAppStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -26,7 +34,7 @@ export function AppShell() {
|
|||||||
<>
|
<>
|
||||||
<ResizablePanel defaultSize={paneSizes.sidebar} minSize={15} maxSize={40}>
|
<ResizablePanel defaultSize={paneSizes.sidebar} minSize={15} maxSize={40}>
|
||||||
<aside className="h-full border-r border-border bg-card/10 p-3 text-sm text-muted-foreground">
|
<aside className="h-full border-r border-border bg-card/10 p-3 text-sm text-muted-foreground">
|
||||||
File tree placeholder
|
<Sidebar />
|
||||||
</aside>
|
</aside>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle />
|
<ResizableHandle />
|
||||||
|
|||||||
@@ -1,7 +1,127 @@
|
|||||||
export function TabBar() {
|
import { X } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
closestCenter,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
horizontalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { useFileStore, type OpenTab } from '@/stores/file-store';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function SortableTab({
|
||||||
|
tab,
|
||||||
|
isActive,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
tab: OpenTab;
|
||||||
|
isActive: boolean;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onClose: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: tab.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-9 items-center border-b border-border bg-card/20 px-3 text-xs text-muted-foreground">
|
<div
|
||||||
<span>No files open</span>
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
data-testid={`tab-${tab.id}`}
|
||||||
|
className={cn(
|
||||||
|
'group flex h-full cursor-pointer select-none items-center gap-1 border-r border-border px-3 text-xs transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
||||||
|
)}
|
||||||
|
onClick={() => onSelect(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.dirty && (
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary"
|
||||||
|
aria-label="Unsaved changes"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="max-w-[120px] truncate">{tab.title}</span>
|
||||||
|
<button
|
||||||
|
aria-label={`Close ${tab.title}`}
|
||||||
|
className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-muted-foreground/20 transition-opacity"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose(tab.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TabBar() {
|
||||||
|
const openTabs = useFileStore((s) => s.openTabs);
|
||||||
|
const activeTabId = useFileStore((s) => s.activeTabId);
|
||||||
|
const setActiveTab = useFileStore((s) => s.setActiveTab);
|
||||||
|
const closeTab = useFileStore((s) => s.closeTab);
|
||||||
|
const reorderTabs = useFileStore((s) => s.reorderTabs);
|
||||||
|
|
||||||
|
// Require a small drag distance before activating — so a click on the tab
|
||||||
|
// body still triggers `onClick` (dnd-kit's PointerSensor default is 0).
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||||
|
|
||||||
|
if (openTabs.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-9 items-center border-b border-border bg-card/20 px-3 text-xs text-muted-foreground">
|
||||||
|
<span>No files open</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd(e: DragEndEvent) {
|
||||||
|
const { active, over } = e;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const fromIndex = openTabs.findIndex((t) => t.id === active.id);
|
||||||
|
const toIndex = openTabs.findIndex((t) => t.id === over.id);
|
||||||
|
if (fromIndex === -1 || toIndex === -1) return;
|
||||||
|
reorderTabs(fromIndex, toIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-9 items-center border-b border-border bg-card/20 overflow-x-auto">
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<SortableContext items={openTabs.map((t) => t.id)} strategy={horizontalListSortingStrategy}>
|
||||||
|
<div className="flex h-full items-center px-1">
|
||||||
|
{openTabs.map((tab) => (
|
||||||
|
<SortableTab
|
||||||
|
key={tab.id}
|
||||||
|
tab={tab}
|
||||||
|
isActive={tab.id === activeTabId}
|
||||||
|
onSelect={setActiveTab}
|
||||||
|
onClose={closeTab}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,77 @@
|
|||||||
import { Bold, Italic, List, ListOrdered, Code, Link as LinkIcon } from 'lucide-react';
|
import { Bold, Italic, List, ListOrdered, Code, Link as LinkIcon, PanelLeft, PanelRight, Save, FolderOpen, FileText } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
export function Toolbar() {
|
export function Toolbar() {
|
||||||
|
const dispatch = useCommandStore((s) => s.dispatch);
|
||||||
|
const { sidebarVisible, previewVisible } = useAppStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-10 items-center gap-1 border-b border-border bg-card/10 px-3">
|
<div
|
||||||
<Button variant="ghost" size="icon" aria-label="Bold"><Bold className="h-4 w-4" /></Button>
|
role="toolbar"
|
||||||
<Button variant="ghost" size="icon" aria-label="Italic"><Italic className="h-4 w-4" /></Button>
|
aria-label="Main toolbar"
|
||||||
<Button variant="ghost" size="icon" aria-label="Unordered list"><List className="h-4 w-4" /></Button>
|
className="flex h-10 items-center gap-1 border-b border-border bg-card/10 px-3"
|
||||||
<Button variant="ghost" size="icon" aria-label="Ordered list"><ListOrdered className="h-4 w-4" /></Button>
|
>
|
||||||
<Button variant="ghost" size="icon" aria-label="Code"><Code className="h-4 w-4" /></Button>
|
<Button
|
||||||
<Button variant="ghost" size="icon" aria-label="Link"><LinkIcon className="h-4 w-4" /></Button>
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Open file"
|
||||||
|
data-testid="toolbar-open-file"
|
||||||
|
onClick={() => dispatch('file.open')}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Open folder"
|
||||||
|
data-testid="toolbar-open-folder"
|
||||||
|
onClick={() => dispatch('file.openFolder')}
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Save"
|
||||||
|
data-testid="toolbar-save"
|
||||||
|
onClick={() => dispatch('file.save')}
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
|
aria-pressed={sidebarVisible}
|
||||||
|
data-testid="toolbar-toggle-sidebar"
|
||||||
|
onClick={() => dispatch('view.toggleSidebar')}
|
||||||
|
>
|
||||||
|
<PanelLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Toggle preview"
|
||||||
|
aria-pressed={previewVisible}
|
||||||
|
data-testid="toolbar-toggle-preview"
|
||||||
|
onClick={() => dispatch('view.togglePreview')}
|
||||||
|
>
|
||||||
|
<PanelRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||||
|
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Bold" disabled><Bold className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Italic" disabled><Italic className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Unordered list" disabled><List className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Ordered list" disabled><ListOrdered className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Code" disabled><Code className="h-4 w-4" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="Link" disabled><LinkIcon className="h-4 w-4" /></Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { ChevronRight, Folder, FolderOpen, File, FileText } from 'lucide-react';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function FileTreeNode({ node, depth }: { node: import('@/stores/file-store').FileNode; depth: number }) {
|
||||||
|
const { expanded, activeTabId, loadChildren, toggleExpanded, openFile } = useFileStore();
|
||||||
|
const isExpanded = expanded.has(node.path);
|
||||||
|
const isActive = activeTabId === node.path;
|
||||||
|
|
||||||
|
if (node.isDirectory) {
|
||||||
|
const handleClick = async () => {
|
||||||
|
if (!node.loaded) {
|
||||||
|
await loadChildren(node.path);
|
||||||
|
}
|
||||||
|
toggleExpanded(node.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-1 rounded px-1 py-0.5 text-left text-xs hover:bg-accent',
|
||||||
|
isActive && 'bg-accent'
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${depth * 16 + 4}px` }}
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
size={12}
|
||||||
|
className={cn('transition-transform duration-150', isExpanded && 'rotate-90')}
|
||||||
|
/>
|
||||||
|
{isExpanded ? <FolderOpen size={14} /> : <Folder size={14} />}
|
||||||
|
<span className="truncate">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
{isExpanded && node.loaded && node.children && (
|
||||||
|
<div>
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<FileTreeNode key={child.path} node={child} depth={depth + 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => openFile(node.path)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-1 rounded px-1 py-0.5 text-left text-xs hover:bg-accent',
|
||||||
|
isActive && 'bg-accent text-accent-foreground'
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${depth * 16 + 4}px` }}
|
||||||
|
>
|
||||||
|
<FileText size={14} />
|
||||||
|
<span className="truncate">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileTree() {
|
||||||
|
const tree = useFileStore((s) => s.tree);
|
||||||
|
|
||||||
|
if (!tree) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-1">
|
||||||
|
{tree.children?.map((child) => (
|
||||||
|
<FileTreeNode key={child.path} node={child} depth={0} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
const HEADING_RE = /^(#{1,6})\s+(.+)$/gm;
|
||||||
|
|
||||||
|
interface HeadingItem {
|
||||||
|
level: number;
|
||||||
|
text: string;
|
||||||
|
line: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractHeadings(content: string): HeadingItem[] {
|
||||||
|
const headings: HeadingItem[] = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
const re = new RegExp(HEADING_RE.source, 'gm');
|
||||||
|
let lineNum = 1;
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
re.lastIndex = 0;
|
||||||
|
match = re.exec(line);
|
||||||
|
if (match) {
|
||||||
|
headings.push({
|
||||||
|
level: match[1].length,
|
||||||
|
text: match[2].trim(),
|
||||||
|
line: lineNum,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lineNum++;
|
||||||
|
}
|
||||||
|
return headings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Outline() {
|
||||||
|
const activeId = useEditorStore((s) => s.activeId);
|
||||||
|
const buffers = useEditorStore((s) => s.buffers);
|
||||||
|
|
||||||
|
if (!activeId) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-8 text-xs text-muted-foreground">
|
||||||
|
<span>No file open</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = buffers.get(activeId);
|
||||||
|
if (!buffer) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-8 text-xs text-muted-foreground">
|
||||||
|
<span>No file open</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headings = extractHeadings(buffer.content);
|
||||||
|
|
||||||
|
if (headings.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-8 text-xs text-muted-foreground">
|
||||||
|
<span>No headings</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5 py-1">
|
||||||
|
{headings.map((h, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => {}}
|
||||||
|
className="flex items-center gap-1 rounded px-2 py-0.5 text-left text-xs hover:bg-accent"
|
||||||
|
style={{ paddingLeft: `${(h.level - 1) * 16 + 8}px` }}
|
||||||
|
>
|
||||||
|
<span className="truncate text-muted-foreground">#{h.level}</span>
|
||||||
|
<span className="truncate">{h.text}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { ChevronRight, FolderOpen } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { FileTree } from './FileTree';
|
||||||
|
import { Outline } from './Outline';
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const tree = useFileStore((s) => s.tree);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3">
|
||||||
|
{/* Files section */}
|
||||||
|
<Collapsible defaultOpen>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-medium hover:bg-accent rounded">
|
||||||
|
<ChevronRight size={12} className="rotate-90" />
|
||||||
|
Files
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<ScrollArea className="h-[calc(100vh-240px)]">
|
||||||
|
{!tree ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 p-4 text-xs text-muted-foreground">
|
||||||
|
<span>No folder opened</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => useFileStore.getState().openFolderDialog()}>
|
||||||
|
<FolderOpen className="mr-1" /> Open Folder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FileTree />
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* Outline section */}
|
||||||
|
<Collapsible defaultOpen>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button className="flex w-full items-center gap-1.5 px-1 py-1.5 text-xs font-medium hover:bg-accent rounded">
|
||||||
|
<ChevronRight size={12} className="rotate-90" />
|
||||||
|
Outline
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<ScrollArea className="h-[calc(100vh-240px)]">
|
||||||
|
<Outline />
|
||||||
|
</ScrollArea>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
const Collapsible = CollapsiblePrimitive.Root
|
||||||
|
|
||||||
|
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
|
||||||
|
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ContextMenu = ContextMenuPrimitive.Root
|
||||||
|
|
||||||
|
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const ContextMenuGroup = ContextMenuPrimitive.Group
|
||||||
|
|
||||||
|
const ContextMenuPortal = ContextMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const ContextMenuSub = ContextMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const ContextMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const ContextMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const ContextMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const ContextMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const ContextMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
ContextMenuCheckboxItem.displayName =
|
||||||
|
ContextMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const ContextMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-4 w-4 fill-current" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const ContextMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const ContextMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const ContextMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ContextMenuShortcut.displayName = "ContextMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
))
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
))
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global keyboard shortcuts for file operations.
|
||||||
|
*
|
||||||
|
* - Cmd/Ctrl+O: open file
|
||||||
|
* - Cmd/Ctrl+Shift+O: open folder
|
||||||
|
* - Cmd/Ctrl+S: save active buffer
|
||||||
|
* - Cmd/Ctrl+W: close active tab
|
||||||
|
* - Cmd/Ctrl+Tab / Cmd/Ctrl+Shift+Tab: next/previous tab (wraps)
|
||||||
|
*
|
||||||
|
* Suppressed when an <input>, <textarea>, or contentEditable element has focus.
|
||||||
|
*/
|
||||||
|
export function useFileShortcuts(): void {
|
||||||
|
useEffect(() => {
|
||||||
|
function isTypingTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
const tag = target.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return true;
|
||||||
|
if (target.isContentEditable) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle(e: KeyboardEvent): void {
|
||||||
|
const mod = e.metaKey || e.ctrlKey;
|
||||||
|
if (!mod) return;
|
||||||
|
if (isTypingTarget(e.target)) return;
|
||||||
|
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
|
||||||
|
// Order matters: more specific (Shift) variants first so they don't get
|
||||||
|
// shadowed by the plain modifier-only branch.
|
||||||
|
if (e.shiftKey && key === 'o') {
|
||||||
|
e.preventDefault();
|
||||||
|
void useFileStore.getState().openFolderDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!e.shiftKey && key === 'o') {
|
||||||
|
e.preventDefault();
|
||||||
|
void useFileStore.getState().openFileDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!e.shiftKey && key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
void useFileStore.getState().saveActiveBuffer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!e.shiftKey && key === 'w') {
|
||||||
|
e.preventDefault();
|
||||||
|
const { activeTabId, closeTab } = useFileStore.getState();
|
||||||
|
if (activeTabId) closeTab(activeTabId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
const { openTabs, activeTabId, setActiveTab } = useFileStore.getState();
|
||||||
|
if (openTabs.length === 0) return;
|
||||||
|
const idx = activeTabId ? openTabs.findIndex((t) => t.id === activeTabId) : 0;
|
||||||
|
const safeIdx = idx === -1 ? 0 : idx;
|
||||||
|
const nextIdx = e.shiftKey
|
||||||
|
? (safeIdx <= 0 ? openTabs.length - 1 : safeIdx - 1)
|
||||||
|
: (safeIdx >= openTabs.length - 1 ? 0 : safeIdx + 1);
|
||||||
|
setActiveTab(openTabs[nextIdx].id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handle);
|
||||||
|
return () => window.removeEventListener('keydown', handle);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
|
import type { CommandId } from '@/stores/command-store';
|
||||||
|
|
||||||
|
type Transform<Args = unknown> = (...payload: unknown[]) => Args;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge a native-menu IPC channel to a command-store dispatch.
|
||||||
|
*
|
||||||
|
* When the main process sends `channel` (e.g. `'file-save'`), the optional
|
||||||
|
* `transform` converts the payload to the command's args, then the command
|
||||||
|
* `commandId` is dispatched through the command store.
|
||||||
|
*
|
||||||
|
* The hook cleans up its IPC subscription on unmount.
|
||||||
|
*/
|
||||||
|
export function useMenuAction<Args = unknown>(
|
||||||
|
channel: string,
|
||||||
|
commandId: CommandId,
|
||||||
|
transform?: Transform<Args>
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = ipc.menu.on(channel, (...payload: unknown[]) => {
|
||||||
|
const args = transform ? transform(...payload) : (payload[0] as Args);
|
||||||
|
useCommandStore.getState().dispatch(commandId, args);
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
}, [channel, commandId, transform]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On mount, re-opens the last folder if one was persisted via the file store's
|
||||||
|
* zustand persist middleware. Idempotent — re-running with the same path is a
|
||||||
|
* no-op (openFolder's list IPC call would just re-fetch, which is acceptable).
|
||||||
|
*/
|
||||||
|
export function useRestoreLastFolder(): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const { rootPath, openFolder, tree } = useFileStore.getState();
|
||||||
|
if (rootPath && !tree) {
|
||||||
|
void openFolder(rootPath);
|
||||||
|
}
|
||||||
|
// Run once on mount only. Persist hydration happens before AppShell renders.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind a keyboard shortcut to a callback.
|
||||||
|
*
|
||||||
|
* Combos use `+` separators. Tokens:
|
||||||
|
* - `mod` → meta (Mac) or ctrl (others)
|
||||||
|
* - `ctrl` → ctrl only
|
||||||
|
* - `meta` → meta only
|
||||||
|
* - `alt` → alt / option
|
||||||
|
* - `shift` → shift
|
||||||
|
* - any single char (case-insensitive), e.g. `s`, `S`
|
||||||
|
* - special names: `Tab`, `Enter`, `Escape`, `Space`, `ArrowUp/Down/Left/Right`
|
||||||
|
*
|
||||||
|
* The hook is suppressed when the keydown target is an `<input>`, `<textarea>`,
|
||||||
|
* or contentEditable element, mirroring the standard browser shortcut pattern.
|
||||||
|
*
|
||||||
|
* On match, `event.preventDefault()` is called.
|
||||||
|
*/
|
||||||
|
export function useShortcut(combo: string, callback: (e: KeyboardEvent) => void): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const tokens = parseCombo(combo);
|
||||||
|
|
||||||
|
function isTypingTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
const tag = target.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return true;
|
||||||
|
if (target.isContentEditable) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle(e: KeyboardEvent): void {
|
||||||
|
if (isTypingTarget(e.target)) return;
|
||||||
|
if (!matchCombo(tokens, e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
callback(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handle);
|
||||||
|
return () => window.removeEventListener('keydown', handle);
|
||||||
|
}, [combo, callback]);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComboSpec {
|
||||||
|
mod?: boolean;
|
||||||
|
ctrl?: boolean;
|
||||||
|
meta?: boolean;
|
||||||
|
alt?: boolean;
|
||||||
|
shift?: boolean;
|
||||||
|
key: string; // lowercased
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCombo(combo: string): ComboSpec {
|
||||||
|
const parts = combo
|
||||||
|
.split('+')
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const spec: ComboSpec = { key: '' };
|
||||||
|
for (const p of parts) {
|
||||||
|
const lower = p.toLowerCase();
|
||||||
|
if (lower === 'mod') spec.mod = true;
|
||||||
|
else if (lower === 'ctrl' || lower === 'control') spec.ctrl = true;
|
||||||
|
else if (lower === 'meta' || lower === 'cmd' || lower === 'command') spec.meta = true;
|
||||||
|
else if (lower === 'alt' || lower === 'option') spec.alt = true;
|
||||||
|
else if (lower === 'shift') spec.shift = true;
|
||||||
|
else spec.key = lower;
|
||||||
|
}
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchCombo(spec: ComboSpec, e: KeyboardEvent): boolean {
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
if (key !== spec.key) return false;
|
||||||
|
|
||||||
|
if (spec.mod) {
|
||||||
|
if (!(e.metaKey || e.ctrlKey)) return false;
|
||||||
|
}
|
||||||
|
if (spec.ctrl && !e.ctrlKey) return false;
|
||||||
|
if (spec.meta && !e.metaKey) return false;
|
||||||
|
if (spec.alt && !e.altKey) return false;
|
||||||
|
if (spec.shift && !e.shiftKey) return false;
|
||||||
|
|
||||||
|
// Reject extra modifiers that the spec did not ask for.
|
||||||
|
// (e.g. spec says "mod+s" but user pressed "mod+shift+s" → no match.)
|
||||||
|
if (!spec.shift && e.shiftKey) return false;
|
||||||
|
if (!spec.alt && e.altKey) return false;
|
||||||
|
// ctrl/meta: if spec did not require mod, ctrl or meta must be absent.
|
||||||
|
if (!spec.mod && !spec.ctrl && e.ctrlKey) return false;
|
||||||
|
if (!spec.mod && !spec.meta && e.metaKey) return false;
|
||||||
|
// If spec said "mod" only, ctrl+meta is allowed (mod is either-or).
|
||||||
|
if (spec.mod && !spec.ctrl && !spec.meta) {
|
||||||
|
// ok
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { useMenuAction } from '@/hooks/use-menu-action';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all Phase 6 menu commands in the command store, and bridge
|
||||||
|
* the native menu IPC channels to the matching command ids.
|
||||||
|
*
|
||||||
|
* Phase 6 scope: file/view/tab commands with direct store mappings.
|
||||||
|
* Phase 7+ will add the dialog-driven commands (export, settings, etc.)
|
||||||
|
* once the corresponding modals exist.
|
||||||
|
*/
|
||||||
|
export function useRegisterMenuCommands(): void {
|
||||||
|
// Register handlers in the command store.
|
||||||
|
useEffect(() => {
|
||||||
|
const { register } = useCommandStore.getState();
|
||||||
|
register('file.open', () => {
|
||||||
|
void useFileStore.getState().openFileDialog();
|
||||||
|
});
|
||||||
|
register('file.openFolder', () => {
|
||||||
|
void useFileStore.getState().openFolderDialog();
|
||||||
|
});
|
||||||
|
register('file.save', () => {
|
||||||
|
void useFileStore.getState().saveActiveBuffer();
|
||||||
|
});
|
||||||
|
register('file.closeTab', () => {
|
||||||
|
const { activeTabId, closeTab } = useFileStore.getState();
|
||||||
|
if (activeTabId) closeTab(activeTabId);
|
||||||
|
});
|
||||||
|
register('tab.next', () => {
|
||||||
|
const { openTabs, activeTabId, setActiveTab } = useFileStore.getState();
|
||||||
|
if (openTabs.length === 0) return;
|
||||||
|
const idx = activeTabId ? openTabs.findIndex((t) => t.id === activeTabId) : 0;
|
||||||
|
const safeIdx = idx === -1 ? 0 : idx;
|
||||||
|
const nextIdx = safeIdx >= openTabs.length - 1 ? 0 : safeIdx + 1;
|
||||||
|
setActiveTab(openTabs[nextIdx].id);
|
||||||
|
});
|
||||||
|
register('tab.prev', () => {
|
||||||
|
const { openTabs, activeTabId, setActiveTab } = useFileStore.getState();
|
||||||
|
if (openTabs.length === 0) return;
|
||||||
|
const idx = activeTabId ? openTabs.findIndex((t) => t.id === activeTabId) : 0;
|
||||||
|
const safeIdx = idx === -1 ? 0 : idx;
|
||||||
|
const nextIdx = safeIdx <= 0 ? openTabs.length - 1 : safeIdx - 1;
|
||||||
|
setActiveTab(openTabs[nextIdx].id);
|
||||||
|
});
|
||||||
|
register('view.toggleSidebar', () => useAppStore.getState().toggleSidebar());
|
||||||
|
register('view.togglePreview', () => useAppStore.getState().togglePreview());
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire native-menu IPC channels to the command store.
|
||||||
|
* Channel names match what main.js dispatches via webContents.send.
|
||||||
|
*/
|
||||||
|
export function useBridgeNativeMenu(): void {
|
||||||
|
useMenuAction('file-save', 'file.save');
|
||||||
|
useMenuAction('toggle-preview', 'view.togglePreview');
|
||||||
|
useMenuAction('toggle-command-palette', 'command.palette');
|
||||||
|
useMenuAction('toggle-sidebar-panel', 'view.sidebarPanel', (panel) => panel as string);
|
||||||
|
useMenuAction('toggle-bottom-panel', 'view.bottomPanel');
|
||||||
|
useMenuAction('toggle-find', 'find.toggle');
|
||||||
|
useMenuAction('undo', 'editor.undo');
|
||||||
|
useMenuAction('redo', 'editor.redo');
|
||||||
|
useMenuAction('adjust-font-size', 'font.size', (direction) => direction as string);
|
||||||
|
useMenuAction('load-custom-css', 'theme.loadCustomCss');
|
||||||
|
useMenuAction('clear-custom-css', 'theme.clearCustomCss');
|
||||||
|
useMenuAction('load-template-menu', 'template.load', (name) => name as string);
|
||||||
|
useMenuAction('print-preview', 'print.preview');
|
||||||
|
useMenuAction('print-preview-styled', 'print.previewStyled');
|
||||||
|
useMenuAction('file-opened', 'file.opened', (payload) => payload);
|
||||||
|
useMenuAction('clear-recent-files', 'file.clearRecent');
|
||||||
|
}
|
||||||
@@ -66,6 +66,10 @@ export const ipc = {
|
|||||||
safeCall('file', 'write', path, content),
|
safeCall('file', 'write', path, content),
|
||||||
list: (dir: string): Promise<IpcResult<FileEntry[] | ChannelMissing>> =>
|
list: (dir: string): Promise<IpcResult<FileEntry[] | ChannelMissing>> =>
|
||||||
safeCall('file', 'list', dir),
|
safeCall('file', 'list', dir),
|
||||||
|
pickFolder: (): Promise<IpcResult<string | null | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'pickFolder'),
|
||||||
|
pickFile: (): Promise<IpcResult<string | null | ChannelMissing>> =>
|
||||||
|
safeCall('file', 'pickFile'),
|
||||||
onChange: (cb: (path: string) => void): (() => void) => {
|
onChange: (cb: (path: string) => void): (() => void) => {
|
||||||
if (typeof window === 'undefined' || !window.electronAPI?.file?.onChange) {
|
if (typeof window === 'undefined' || !window.electronAPI?.file?.onChange) {
|
||||||
return () => {};
|
return () => {};
|
||||||
@@ -91,4 +95,16 @@ export const ipc = {
|
|||||||
showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> =>
|
showItemInFolder: (path: string): Promise<IpcResult<void | ChannelMissing>> =>
|
||||||
safeCall('app', 'showItemInFolder', path),
|
safeCall('app', 'showItemInFolder', path),
|
||||||
},
|
},
|
||||||
|
menu: {
|
||||||
|
/**
|
||||||
|
* Subscribe to a native-menu IPC channel. The callback receives the
|
||||||
|
* raw payload(s) from the main process. Returns an unsubscribe fn.
|
||||||
|
*/
|
||||||
|
on: (channel: string, callback: (...args: unknown[]) => void): (() => void) => {
|
||||||
|
if (typeof window === 'undefined' || !window.electronAPI?.on) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
return window.electronAPI.on(channel, callback as (...a: any[]) => void);
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
export type CommandId = string;
|
||||||
|
|
||||||
|
export type CommandHandler = (args?: unknown) => void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-customizable key binding for a command.
|
||||||
|
* Format: same as useShortcut combo ('mod+s', 'mod+shift+s', etc.)
|
||||||
|
* Undefined means "use the default binding" (if any).
|
||||||
|
*/
|
||||||
|
export type UserBindings = Record<string, string>;
|
||||||
|
|
||||||
|
interface CommandState {
|
||||||
|
handlers: Record<string, CommandHandler>;
|
||||||
|
userBindings: UserBindings;
|
||||||
|
register: (id: CommandId, handler: CommandHandler) => void;
|
||||||
|
registerMany: (handlers: Record<string, CommandHandler>) => void;
|
||||||
|
unregister: (id: CommandId) => void;
|
||||||
|
dispatch: (id: CommandId, args?: unknown) => void;
|
||||||
|
get: (id: CommandId) => CommandHandler | undefined;
|
||||||
|
setUserBinding: (id: CommandId, combo: string) => void;
|
||||||
|
clearUserBinding: (id: CommandId) => void;
|
||||||
|
getUserBinding: (id: CommandId) => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCommandStore = create<CommandState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
handlers: {},
|
||||||
|
userBindings: {},
|
||||||
|
register: (id, handler) =>
|
||||||
|
set((s) => ({ handlers: { ...s.handlers, [id]: handler } })),
|
||||||
|
registerMany: (handlers) =>
|
||||||
|
set((s) => ({ handlers: { ...s.handlers, ...handlers } })),
|
||||||
|
unregister: (id) =>
|
||||||
|
set((s) => {
|
||||||
|
const next = { ...s.handlers };
|
||||||
|
delete next[id];
|
||||||
|
return { handlers: next };
|
||||||
|
}),
|
||||||
|
dispatch: (id, args) => {
|
||||||
|
const handler = get().handlers[id];
|
||||||
|
if (handler) {
|
||||||
|
void handler(args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get: (id) => get().handlers[id],
|
||||||
|
setUserBinding: (id, combo) =>
|
||||||
|
set((s) => ({ userBindings: { ...s.userBindings, [id]: combo } })),
|
||||||
|
clearUserBinding: (id) =>
|
||||||
|
set((s) => {
|
||||||
|
const next = { ...s.userBindings };
|
||||||
|
delete next[id];
|
||||||
|
return { userBindings: next };
|
||||||
|
}),
|
||||||
|
getUserBinding: (id) => get().userBindings[id],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'mc-command-store',
|
||||||
|
// Only persist user-customizable bindings; handlers are runtime-only.
|
||||||
|
partialize: (state) => ({ userBindings: state.userBindings }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
import { immer } from 'zustand/middleware/immer';
|
||||||
|
import { enableMapSet } from 'immer';
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import type { FileEntry } from '@/types/ipc';
|
||||||
|
|
||||||
|
enableMapSet();
|
||||||
|
|
||||||
|
export interface FileNode {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isDirectory: boolean;
|
||||||
|
children: FileNode[] | null;
|
||||||
|
expanded?: boolean;
|
||||||
|
loaded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenTab {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
dirty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileState {
|
||||||
|
tree: FileNode | null;
|
||||||
|
rootPath: string | null;
|
||||||
|
expanded: Set<string>;
|
||||||
|
openTabs: OpenTab[];
|
||||||
|
activeTabId: string | null;
|
||||||
|
|
||||||
|
openFolder: (path: string) => Promise<void>;
|
||||||
|
loadChildren: (dirPath: string) => Promise<void>;
|
||||||
|
toggleExpanded: (dirPath: string) => void;
|
||||||
|
openFile: (filePath: string) => Promise<void>;
|
||||||
|
closeTab: (id: string) => void;
|
||||||
|
setActiveTab: (id: string) => void;
|
||||||
|
markTabClean: (id: string) => void;
|
||||||
|
markTabDirty: (id: string) => void;
|
||||||
|
reorderTabs: (fromIndex: number, toIndex: number) => void;
|
||||||
|
openFolderDialog: () => Promise<void>;
|
||||||
|
openFileDialog: () => Promise<void>;
|
||||||
|
saveActiveBuffer: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryToNode(entry: FileEntry): FileNode {
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
path: entry.path,
|
||||||
|
isDirectory: entry.isDirectory,
|
||||||
|
children: null, // lazy: start unloaded; loadChildren populates this
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively find and update a directory node by path using immer's produce
|
||||||
|
function updateNode(tree: FileNode, dirPath: string, updater: (node: FileNode) => void): boolean {
|
||||||
|
if (tree.path === dirPath && tree.isDirectory) {
|
||||||
|
updater(tree);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (tree.children) {
|
||||||
|
for (const child of tree.children) {
|
||||||
|
if (updateNode(child, dirPath, updater)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFileStore = create<FileState>()(
|
||||||
|
persist(
|
||||||
|
immer((set) => ({
|
||||||
|
tree: null,
|
||||||
|
rootPath: null,
|
||||||
|
expanded: new Set<string>(),
|
||||||
|
openTabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
|
||||||
|
openFolder: async (path) => {
|
||||||
|
const result = await ipc.file.list(path);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const children: FileNode[] = result.data!.map(entryToNode);
|
||||||
|
|
||||||
|
set((s) => {
|
||||||
|
s.tree = {
|
||||||
|
name: path.split('/').filter(Boolean).pop() ?? path,
|
||||||
|
path,
|
||||||
|
isDirectory: true,
|
||||||
|
children,
|
||||||
|
loaded: true,
|
||||||
|
};
|
||||||
|
s.rootPath = path;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
loadChildren: async (dirPath) => {
|
||||||
|
// Find the node first
|
||||||
|
const tree = useFileStore.getState().tree;
|
||||||
|
if (!tree) return;
|
||||||
|
|
||||||
|
let found = false;
|
||||||
|
updateNode(tree, dirPath, (node) => {
|
||||||
|
if (node.loaded === true) return;
|
||||||
|
found = true;
|
||||||
|
});
|
||||||
|
if (!found) return;
|
||||||
|
|
||||||
|
const result = await ipc.file.list(dirPath);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
set((s) => {
|
||||||
|
updateNode(s.tree!, dirPath, (node) => {
|
||||||
|
node.children = result.data!.map(entryToNode);
|
||||||
|
node.loaded = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleExpanded: (dirPath) => {
|
||||||
|
set((s) => {
|
||||||
|
if (s.expanded.has(dirPath)) {
|
||||||
|
s.expanded.delete(dirPath);
|
||||||
|
} else {
|
||||||
|
s.expanded.add(dirPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
openFile: async (filePath) => {
|
||||||
|
// If already open, sync dirty flag from editor buffer and activate
|
||||||
|
const existing = useFileStore.getState().openTabs.find((t) => t.id === filePath);
|
||||||
|
if (existing) {
|
||||||
|
const buffer = useEditorStore.getState().buffers.get(filePath);
|
||||||
|
set((s) => {
|
||||||
|
s.activeTabId = filePath;
|
||||||
|
if (buffer) {
|
||||||
|
const tab = s.openTabs.find((t) => t.id === filePath);
|
||||||
|
if (tab) tab.dirty = buffer.dirty;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ipc.file.read(filePath);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const content = result.data!;
|
||||||
|
const title = filePath.split('/').pop() ?? filePath;
|
||||||
|
|
||||||
|
// Open in editor store
|
||||||
|
useEditorStore.getState().openBuffer(filePath, filePath, content);
|
||||||
|
|
||||||
|
set((s) => {
|
||||||
|
s.openTabs.push({ id: filePath, path: filePath, title, dirty: false });
|
||||||
|
s.activeTabId = filePath;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
closeTab: (id) => {
|
||||||
|
set((s) => {
|
||||||
|
const idx = s.openTabs.findIndex((t) => t.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
s.openTabs.splice(idx, 1);
|
||||||
|
if (s.activeTabId === id) {
|
||||||
|
s.activeTabId = idx > 0 ? s.openTabs[idx - 1]?.id ?? null : null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveTab: (id) => {
|
||||||
|
set((s) => {
|
||||||
|
s.activeTabId = id;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
markTabClean: (id) => {
|
||||||
|
set((s) => {
|
||||||
|
const tab = s.openTabs.find((t) => t.id === id);
|
||||||
|
if (tab) tab.dirty = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
markTabDirty: (id) => {
|
||||||
|
set((s) => {
|
||||||
|
const tab = s.openTabs.find((t) => t.id === id);
|
||||||
|
if (tab) tab.dirty = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
reorderTabs: (fromIndex, toIndex) => {
|
||||||
|
set((s) => {
|
||||||
|
const tabs = s.openTabs;
|
||||||
|
const [moved] = tabs.splice(fromIndex, 1);
|
||||||
|
tabs.splice(toIndex, 0, moved);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
openFolderDialog: async () => {
|
||||||
|
const result = await ipc.file.pickFolder();
|
||||||
|
if (!result.ok || result.data === null) return;
|
||||||
|
await useFileStore.getState().openFolder(result.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
openFileDialog: async () => {
|
||||||
|
const result = await ipc.file.pickFile();
|
||||||
|
if (!result.ok || result.data === null) return;
|
||||||
|
await useFileStore.getState().openFile(result.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
saveActiveBuffer: async () => {
|
||||||
|
const { activeTabId } = useFileStore.getState();
|
||||||
|
if (!activeTabId) return false;
|
||||||
|
|
||||||
|
const buffer = useEditorStore.getState().buffers.get(activeTabId);
|
||||||
|
if (!buffer) return false;
|
||||||
|
|
||||||
|
const writeResult = await ipc.file.write(activeTabId, buffer.content);
|
||||||
|
if (!writeResult.ok) return false;
|
||||||
|
|
||||||
|
useEditorStore.getState().markSaved(activeTabId);
|
||||||
|
useFileStore.getState().markTabClean(activeTabId);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
name: 'mc-file-store',
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
// Only persist the folder path; tree, expanded Set, and openTabs are
|
||||||
|
// rebuilt on demand. The app re-opens the folder on startup.
|
||||||
|
partialize: (state) => ({ rootPath: state.rootPath }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -4,11 +4,21 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { ThemeProvider } from '@/components/theme-provider';
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
import { AppHeader } from '@/components/layout/AppHeader';
|
import { AppHeader } from '@/components/layout/AppHeader';
|
||||||
import { useAppStore } from '@/stores/app-store';
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
|
|
||||||
describe('AppHeader', () => {
|
describe('AppHeader', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
useAppStore.setState({ sidebarVisible: true, previewVisible: true, zenMode: false });
|
useAppStore.setState({ sidebarVisible: true, previewVisible: true, zenMode: false });
|
||||||
|
useCommandStore.setState({ handlers: {} });
|
||||||
|
// Mirror what AppShell registers so dispatching actually does something.
|
||||||
|
useCommandStore.getState().register('view.toggleSidebar', () => {
|
||||||
|
useAppStore.getState().toggleSidebar();
|
||||||
|
});
|
||||||
|
useCommandStore.getState().register('view.togglePreview', () => {
|
||||||
|
useAppStore.getState().togglePreview();
|
||||||
|
});
|
||||||
|
useCommandStore.getState().register('shortcuts.show', () => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the app title', () => {
|
it('renders the app title', () => {
|
||||||
@@ -30,4 +40,28 @@ describe('AppHeader', () => {
|
|||||||
await userEvent.click(btn);
|
await userEvent.click(btn);
|
||||||
expect(useAppStore.getState().sidebarVisible).toBe(false);
|
expect(useAppStore.getState().sidebarVisible).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('toggles preview when preview button clicked', async () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<AppHeader />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole('button', { name: /toggle preview/i });
|
||||||
|
await userEvent.click(btn);
|
||||||
|
expect(useAppStore.getState().previewVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables-shortcut button dispatches shortcuts.show', async () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<AppHeader />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
const btn = screen.getByTestId('header-shortcuts');
|
||||||
|
await userEvent.click(btn);
|
||||||
|
// No observable side effect; the command was registered as a no-op.
|
||||||
|
// The test passes if no error is thrown and the click is processed.
|
||||||
|
expect(btn).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,10 +1,116 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen, act } from '@testing-library/react';
|
||||||
import { TabBar } from '@/components/layout/TabBar';
|
import { TabBar } from '@/components/layout/TabBar';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
|
||||||
describe('TabBar', () => {
|
describe('TabBar', () => {
|
||||||
it('renders an empty state when no tabs are open', () => {
|
beforeEach(() => {
|
||||||
|
useFileStore.setState({ openTabs: [], activeTabId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "No files open" when openTabs is empty', () => {
|
||||||
render(<TabBar />);
|
render(<TabBar />);
|
||||||
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
|
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
it('renders a tab for each OpenTab', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
|
||||||
|
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
|
||||||
|
],
|
||||||
|
activeTabId: '/tmp/foo.md',
|
||||||
|
});
|
||||||
|
render(<TabBar />);
|
||||||
|
expect(screen.getByText('foo.md')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('bar.md')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights the active tab with aria-current', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
|
||||||
|
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
|
||||||
|
],
|
||||||
|
activeTabId: '/tmp/foo.md',
|
||||||
|
});
|
||||||
|
render(<TabBar />);
|
||||||
|
const fooTab = screen.getByText('foo.md').closest('[role="tab"]');
|
||||||
|
expect(fooTab).toHaveAttribute('aria-current', 'page');
|
||||||
|
const barTab = screen.getByText('bar.md').closest('[role="tab"]');
|
||||||
|
expect(barTab).not.toHaveAttribute('aria-current');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking a tab calls setActiveTab', async () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
|
||||||
|
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
|
||||||
|
],
|
||||||
|
activeTabId: '/tmp/foo.md',
|
||||||
|
});
|
||||||
|
render(<TabBar />);
|
||||||
|
await act(async () => {
|
||||||
|
screen.getByText('bar.md').closest('[role="tab"]')!.click();
|
||||||
|
});
|
||||||
|
expect(useFileStore.getState().activeTabId).toBe('/tmp/bar.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the close button calls closeTab and does not call setActiveTab separately', async () => {
|
||||||
|
// Use a non-active tab to test that closeTab doesn't change activeTabId
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: false },
|
||||||
|
{ id: '/tmp/bar.md', path: '/tmp/bar.md', title: 'bar.md', dirty: false },
|
||||||
|
],
|
||||||
|
activeTabId: '/tmp/bar.md',
|
||||||
|
});
|
||||||
|
render(<TabBar />);
|
||||||
|
|
||||||
|
const fooTab = screen.getByText('foo.md');
|
||||||
|
const closeBtn = fooTab.closest('[role="tab"]')!.querySelector('button')!;
|
||||||
|
await act(async () => {
|
||||||
|
closeBtn.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useFileStore.getState();
|
||||||
|
expect(state.openTabs.find((t) => t.id === '/tmp/foo.md')).toBeUndefined();
|
||||||
|
// activeTabId stays on bar since we closed a non-active tab
|
||||||
|
expect(state.activeTabId).toBe('/tmp/bar.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a dirty indicator when dirty === true', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/tmp/foo.md', path: '/tmp/foo.md', title: 'foo.md', dirty: true },
|
||||||
|
],
|
||||||
|
activeTabId: '/tmp/foo.md',
|
||||||
|
});
|
||||||
|
render(<TabBar />);
|
||||||
|
const dirtyDot = screen.getByLabelText('Unsaved changes');
|
||||||
|
expect(dirtyDot).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reorders tabs when reorderTabs is called (drag end handler integration)', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/tmp/a.md', path: '/tmp/a.md', title: 'a.md', dirty: false },
|
||||||
|
{ id: '/tmp/b.md', path: '/tmp/b.md', title: 'b.md', dirty: false },
|
||||||
|
{ id: '/tmp/c.md', path: '/tmp/c.md', title: 'c.md', dirty: false },
|
||||||
|
],
|
||||||
|
activeTabId: '/tmp/a.md',
|
||||||
|
});
|
||||||
|
render(<TabBar />);
|
||||||
|
// Initial DOM order
|
||||||
|
const initialOrder = screen.getAllByRole('tab').map((el) => el.getAttribute('data-testid'));
|
||||||
|
expect(initialOrder).toEqual(['tab-/tmp/a.md', 'tab-/tmp/b.md', 'tab-/tmp/c.md']);
|
||||||
|
|
||||||
|
// Simulate what dnd-kit's onDragEnd would do: move a.md (idx 0) to idx 2
|
||||||
|
act(() => {
|
||||||
|
useFileStore.getState().reorderTabs(0, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const newOrder = screen.getAllByRole('tab').map((el) => el.getAttribute('data-testid'));
|
||||||
|
expect(newOrder).toEqual(['tab-/tmp/b.md', 'tab-/tmp/c.md', 'tab-/tmp/a.md']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,11 +1,98 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||||
import { Toolbar } from '@/components/layout/Toolbar';
|
import { Toolbar } from '@/components/layout/Toolbar';
|
||||||
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
describe('Toolbar', () => {
|
describe('Toolbar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useCommandStore.setState({ handlers: {} });
|
||||||
|
useAppStore.setState({
|
||||||
|
sidebarVisible: true,
|
||||||
|
previewVisible: true,
|
||||||
|
zenMode: false,
|
||||||
|
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('renders formatting buttons', () => {
|
it('renders formatting buttons', () => {
|
||||||
render(<Toolbar />);
|
render(<Toolbar />);
|
||||||
expect(screen.getByRole('button', { name: /bold/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /bold/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /italic/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /italic/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
it('renders all toolbar buttons', () => {
|
||||||
|
render(<Toolbar />);
|
||||||
|
expect(screen.getByTestId('toolbar-open-file')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('toolbar-open-folder')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('toolbar-save')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('toolbar-toggle-sidebar')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('toolbar-toggle-preview')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Open file button dispatches file.open', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
useCommandStore.getState().register('file.open', handler);
|
||||||
|
render(<Toolbar />);
|
||||||
|
fireEvent.click(screen.getByTestId('toolbar-open-file'));
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Open folder button dispatches file.openFolder', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
useCommandStore.getState().register('file.openFolder', handler);
|
||||||
|
render(<Toolbar />);
|
||||||
|
fireEvent.click(screen.getByTestId('toolbar-open-folder'));
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Save button dispatches file.save', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
useCommandStore.getState().register('file.save', handler);
|
||||||
|
render(<Toolbar />);
|
||||||
|
fireEvent.click(screen.getByTestId('toolbar-save'));
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Toggle sidebar button dispatches view.toggleSidebar and reflects aria-pressed', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
useCommandStore.getState().register('view.toggleSidebar', handler);
|
||||||
|
render(<Toolbar />);
|
||||||
|
const btn = screen.getByTestId('toolbar-toggle-sidebar');
|
||||||
|
expect(btn).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Toggle preview button dispatches view.togglePreview', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
useCommandStore.getState().register('view.togglePreview', handler);
|
||||||
|
render(<Toolbar />);
|
||||||
|
fireEvent.click(screen.getByTestId('toolbar-toggle-preview'));
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggle sidebar end-to-end: command flips appStore state, aria-pressed reflects it', () => {
|
||||||
|
useCommandStore.getState().register('view.toggleSidebar', () => {
|
||||||
|
useAppStore.getState().toggleSidebar();
|
||||||
|
});
|
||||||
|
render(<Toolbar />);
|
||||||
|
const btn = screen.getByTestId('toolbar-toggle-sidebar');
|
||||||
|
expect(btn).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
act(() => {
|
||||||
|
fireEvent.click(btn);
|
||||||
|
});
|
||||||
|
expect(useAppStore.getState().sidebarVisible).toBe(false);
|
||||||
|
expect(btn).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formatting buttons (bold, italic, etc.) are disabled (Phase 9 work)', () => {
|
||||||
|
render(<Toolbar />);
|
||||||
|
expect(screen.getByLabelText('Bold')).toBeDisabled();
|
||||||
|
expect(screen.getByLabelText('Italic')).toBeDisabled();
|
||||||
|
expect(screen.getByLabelText('Unordered list')).toBeDisabled();
|
||||||
|
expect(screen.getByLabelText('Ordered list')).toBeDisabled();
|
||||||
|
expect(screen.getByLabelText('Code')).toBeDisabled();
|
||||||
|
expect(screen.getByLabelText('Link')).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { FileTree } from '@/components/sidebar/FileTree';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
file: {
|
||||||
|
list: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||||
|
read: vi.fn().mockResolvedValue({ ok: true, data: '' }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('FileTree', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useFileStore.setState({ tree: null, rootPath: null, expanded: new Set(), openTabs: [], activeTabId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when tree is null', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<FileTree />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole('button')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders root children after openFolder', async () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: {
|
||||||
|
name: 'project',
|
||||||
|
path: '/project',
|
||||||
|
isDirectory: true,
|
||||||
|
loaded: true,
|
||||||
|
children: [
|
||||||
|
{ name: 'src', path: '/project/src', isDirectory: true, children: null, loaded: false },
|
||||||
|
{ name: 'README.md', path: '/project/README.md', isDirectory: false, children: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rootPath: '/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<FileTree />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('src')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('README.md')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows folder as expanded after toggle and reveals children', async () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: {
|
||||||
|
name: 'project',
|
||||||
|
path: '/project',
|
||||||
|
isDirectory: true,
|
||||||
|
loaded: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'src',
|
||||||
|
path: '/project/src',
|
||||||
|
isDirectory: true,
|
||||||
|
loaded: true,
|
||||||
|
children: [
|
||||||
|
{ name: 'index.ts', path: '/project/src/index.ts', isDirectory: false, children: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rootPath: '/project',
|
||||||
|
expanded: new Set(['/project/src']),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<FileTree />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('index.ts')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights the active tab path', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: {
|
||||||
|
name: 'project',
|
||||||
|
path: '/project',
|
||||||
|
isDirectory: true,
|
||||||
|
loaded: true,
|
||||||
|
children: [
|
||||||
|
{ name: 'README.md', path: '/project/README.md', isDirectory: false, children: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rootPath: '/project',
|
||||||
|
activeTabId: '/project/README.md',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<FileTree />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const btn = screen.getByRole('button', { name: /README.md/i });
|
||||||
|
expect(btn).toHaveClass('bg-accent');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { Outline } from '@/components/sidebar/Outline';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
describe('Outline', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "No file open" when no active buffer', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<Outline />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/no file open/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders H1-H6 headings from the active buffer content', () => {
|
||||||
|
const buffers = new Map([
|
||||||
|
[
|
||||||
|
'b1',
|
||||||
|
{
|
||||||
|
id: 'b1',
|
||||||
|
path: '/x.md',
|
||||||
|
content: '# Hello\n## World\n### Foo\n#### Bar\n##### Baz\n###### Qux',
|
||||||
|
dirty: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
useEditorStore.setState({ buffers, activeId: 'b1' });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<Outline />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Hello')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('World')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Foo')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Bar')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Baz')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Qux')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders headings with level-based indent', () => {
|
||||||
|
const buffers = new Map([
|
||||||
|
[
|
||||||
|
'b1',
|
||||||
|
{
|
||||||
|
id: 'b1',
|
||||||
|
path: '/x.md',
|
||||||
|
content: '# Title\n## Subtitle\n### Section',
|
||||||
|
dirty: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
useEditorStore.setState({ buffers, activeId: 'b1' });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<Outline />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
// H1 has paddingLeft 8px, H2 has24px, H3 has 40px
|
||||||
|
expect(buttons[0]).toHaveStyle('padding-left: 8px');
|
||||||
|
expect(buttons[1]).toHaveStyle('padding-left: 24px');
|
||||||
|
expect(buttons[2]).toHaveStyle('padding-left: 40px');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { Sidebar } from '@/components/sidebar/Sidebar';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
file: {
|
||||||
|
pickFolder: vi.fn(),
|
||||||
|
list: vi.fn(),
|
||||||
|
pickFile: vi.fn(),
|
||||||
|
read: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Sidebar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useFileStore.setState({ tree: null, rootPath: null, expanded: new Set(), openTabs: [], activeTabId: null });
|
||||||
|
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders without crashing with empty state in both sections', () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<Sidebar />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Files')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Outline')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no folder opened/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no file open/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders FileTree and Outline sections', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: {
|
||||||
|
name: 'project',
|
||||||
|
path: '/project',
|
||||||
|
isDirectory: true,
|
||||||
|
loaded: true,
|
||||||
|
children: [{ name: 'README.md', path: '/project/README.md', isDirectory: false, children: null }],
|
||||||
|
},
|
||||||
|
rootPath: '/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<Sidebar />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Files')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Outline')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('README.md')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Open Folder" button when no folder is open', () => {
|
||||||
|
useFileStore.setState({ tree: null, rootPath: null });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<Sidebar />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/no folder opened/i)).toBeInTheDocument();
|
||||||
|
const btn = screen.getByRole('button', { name: /open folder/i });
|
||||||
|
expect(btn).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking "Open Folder" button calls openFolderDialog', async () => {
|
||||||
|
const { ipc } = await import('@/lib/ipc');
|
||||||
|
const spy = vi.spyOn(useFileStore.getState(), 'openFolderDialog');
|
||||||
|
|
||||||
|
useFileStore.setState({ tree: null, rootPath: null });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider defaultTheme="dark" attribute="class">
|
||||||
|
<Sidebar />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const btn = screen.getByRole('button', { name: /open folder/i });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||||
|
import { AppShell } from '@/components/layout/AppShell';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
|
||||||
|
// Mock react-resizable-panels for jsdom (same pattern as AppShell.test.tsx)
|
||||||
|
vi.mock('@/components/ui/resizable', () => ({
|
||||||
|
ResizablePanelGroup: ({ children, direction }: any) => (
|
||||||
|
<div data-testid="resizable-panel-group" data-direction={direction}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
ResizablePanel: ({ children, defaultSize }: any) => (
|
||||||
|
<div data-testid="resizable-panel" data-size={defaultSize}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
ResizableHandle: () => <div data-testid="resizable-handle" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
file: {
|
||||||
|
open: vi.fn().mockResolvedValue({ ok: false, error: { code: 'NO_BRIDGE', message: 'mock' } }),
|
||||||
|
read: vi.fn().mockResolvedValue({ ok: true, data: '# hello' }),
|
||||||
|
write: vi.fn().mockResolvedValue({ ok: true, data: undefined }),
|
||||||
|
list: vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
data: [
|
||||||
|
{ name: 'README.md', path: '/root/README.md', isDirectory: false },
|
||||||
|
{ name: 'src', path: '/root/src', isDirectory: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }),
|
||||||
|
pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }),
|
||||||
|
onChange: vi.fn(() => () => {}),
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
on: vi.fn(() => () => {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Phase 5 integration', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: null,
|
||||||
|
rootPath: null,
|
||||||
|
expanded: new Set(),
|
||||||
|
openTabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
});
|
||||||
|
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||||
|
useAppStore.setState({
|
||||||
|
sidebarVisible: true,
|
||||||
|
previewVisible: true,
|
||||||
|
zenMode: false,
|
||||||
|
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AppShell renders Sidebar (Files + Outline sections) and TabBar', () => {
|
||||||
|
render(<AppShell />);
|
||||||
|
expect(screen.getByText('Files')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Outline')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no files open/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opening a folder via the Open Folder button populates the tree', async () => {
|
||||||
|
render(<AppShell />);
|
||||||
|
// Two buttons share the "Open folder" name (toolbar + sidebar).
|
||||||
|
// Either one triggers the same command, so click the first match.
|
||||||
|
const buttons = screen.getAllByRole('button', { name: /open folder/i });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(buttons[0]);
|
||||||
|
});
|
||||||
|
// After openFolder, the tree should have children
|
||||||
|
const state = useFileStore.getState();
|
||||||
|
expect(state.tree).not.toBeNull();
|
||||||
|
expect(state.tree?.children).toHaveLength(2);
|
||||||
|
expect(state.rootPath).toBe('/root');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking a file in the tree opens it as a tab and populates the editor buffer', async () => {
|
||||||
|
// Seed the tree directly
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: {
|
||||||
|
name: 'root',
|
||||||
|
path: '/root',
|
||||||
|
isDirectory: true,
|
||||||
|
loaded: true,
|
||||||
|
children: [
|
||||||
|
{ name: 'README.md', path: '/root/README.md', isDirectory: false, children: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rootPath: '/root',
|
||||||
|
openTabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AppShell />);
|
||||||
|
const fileButton = screen.getByText('README.md').closest('button')!;
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(fileButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useFileStore.getState();
|
||||||
|
expect(state.openTabs).toHaveLength(1);
|
||||||
|
expect(state.openTabs[0].id).toBe('/root/README.md');
|
||||||
|
expect(state.activeTabId).toBe('/root/README.md');
|
||||||
|
|
||||||
|
// Editor buffer should have the file content
|
||||||
|
const buf = useEditorStore.getState().buffers.get('/root/README.md');
|
||||||
|
expect(buf?.content).toBe('# hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TabBar shows the open tab and switches active on click', async () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: null,
|
||||||
|
rootPath: null,
|
||||||
|
expanded: new Set(),
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false },
|
||||||
|
{ id: '/b.md', path: '/b.md', title: 'b.md', dirty: false },
|
||||||
|
],
|
||||||
|
activeTabId: '/a.md',
|
||||||
|
});
|
||||||
|
render(<AppShell />);
|
||||||
|
const aTab = screen.getByText('a.md').closest('[role="tab"]')!;
|
||||||
|
const bTab = screen.getByText('b.md').closest('[role="tab"]')!;
|
||||||
|
expect(aTab).toHaveAttribute('aria-current', 'page');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(bTab);
|
||||||
|
});
|
||||||
|
expect(useFileStore.getState().activeTabId).toBe('/b.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keyboard shortcut Ctrl+S triggers saveActiveBuffer (no-op when no active tab)', () => {
|
||||||
|
render(<AppShell />);
|
||||||
|
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
|
||||||
|
// No assertion on side effect, just confirms it doesn't throw
|
||||||
|
expect(useFileStore.getState().openTabs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drag-reorder updates the openTabs order', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: null,
|
||||||
|
rootPath: null,
|
||||||
|
expanded: new Set(),
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false },
|
||||||
|
{ id: '/b.md', path: '/b.md', title: 'b.md', dirty: false },
|
||||||
|
{ id: '/c.md', path: '/c.md', title: 'c.md', dirty: false },
|
||||||
|
],
|
||||||
|
activeTabId: '/a.md',
|
||||||
|
});
|
||||||
|
render(<AppShell />);
|
||||||
|
act(() => {
|
||||||
|
useFileStore.getState().reorderTabs(0, 2);
|
||||||
|
});
|
||||||
|
const order = useFileStore.getState().openTabs.map((t) => t.id);
|
||||||
|
expect(order).toEqual(['/b.md', '/c.md', '/a.md']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||||
|
import { AppShell } from '@/components/layout/AppShell';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
|
|
||||||
|
// Mock react-resizable-panels (jsdom can't measure DOM for it)
|
||||||
|
vi.mock('@/components/ui/resizable', () => ({
|
||||||
|
ResizablePanelGroup: ({ children, direction }: any) => (
|
||||||
|
<div data-testid="resizable-panel-group" data-direction={direction}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
ResizablePanel: ({ children, defaultSize }: any) => (
|
||||||
|
<div data-testid="resizable-panel" data-size={defaultSize}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
ResizableHandle: () => <div data-testid="resizable-handle" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const menuListeners = new Map<string, (...args: unknown[]) => void>();
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
file: {
|
||||||
|
open: vi.fn().mockResolvedValue({ ok: false, error: { code: 'NO_BRIDGE', message: 'mock' } }),
|
||||||
|
read: vi.fn().mockResolvedValue({ ok: true, data: '# hello' }),
|
||||||
|
write: vi.fn().mockResolvedValue({ ok: true, data: undefined }),
|
||||||
|
list: vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
data: [{ name: 'README.md', path: '/root/README.md', isDirectory: false }],
|
||||||
|
}),
|
||||||
|
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: '/root' }),
|
||||||
|
pickFile: vi.fn().mockResolvedValue({ ok: true, data: '/root/README.md' }),
|
||||||
|
onChange: vi.fn(() => () => {}),
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
on: vi.fn((channel: string, cb: (...args: unknown[]) => void) => {
|
||||||
|
menuListeners.set(channel, cb);
|
||||||
|
return () => {
|
||||||
|
if (menuListeners.get(channel) === cb) menuListeners.delete(channel);
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function fireMenu(channel: string, ...args: unknown[]): void {
|
||||||
|
const listener = menuListeners.get(channel);
|
||||||
|
if (!listener) throw new Error(`No menu listener for ${channel}`);
|
||||||
|
listener(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Phase 6 integration: menu → command → store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
menuListeners.clear();
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: null,
|
||||||
|
rootPath: null,
|
||||||
|
expanded: new Set(),
|
||||||
|
openTabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
});
|
||||||
|
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||||
|
useAppStore.setState({
|
||||||
|
sidebarVisible: true,
|
||||||
|
previewVisible: true,
|
||||||
|
zenMode: false,
|
||||||
|
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
|
||||||
|
});
|
||||||
|
useCommandStore.setState({ handlers: {}, userBindings: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toolbar Open Folder button opens the folder dialog and populates the tree', async () => {
|
||||||
|
render(<AppShell />);
|
||||||
|
const btn = screen.getByTestId('toolbar-open-folder');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(btn);
|
||||||
|
});
|
||||||
|
expect(useFileStore.getState().tree).not.toBeNull();
|
||||||
|
expect(useFileStore.getState().rootPath).toBe('/root');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toolbar Save button invokes the registered file.save command (which writes)', async () => {
|
||||||
|
// Seed an open buffer
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [{ id: '/root/doc.md', path: '/root/doc.md', title: 'doc.md', dirty: true }],
|
||||||
|
activeTabId: '/root/doc.md',
|
||||||
|
});
|
||||||
|
useEditorStore.setState({
|
||||||
|
buffers: new Map([['/root/doc.md', { id: '/root/doc.md', content: '# x', dirty: true }]]),
|
||||||
|
activeId: '/root/doc.md',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AppShell />);
|
||||||
|
const saveBtn = screen.getByTestId('toolbar-save');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(saveBtn);
|
||||||
|
});
|
||||||
|
// After save, the buffer should be clean
|
||||||
|
expect(useEditorStore.getState().buffers.get('/root/doc.md')?.dirty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('header toggle sidebar button flips sidebarVisible', async () => {
|
||||||
|
render(<AppShell />);
|
||||||
|
const btn = screen.getByTestId('header-toggle-sidebar');
|
||||||
|
expect(btn).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(btn);
|
||||||
|
});
|
||||||
|
expect(useAppStore.getState().sidebarVisible).toBe(false);
|
||||||
|
expect(btn).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('native menu file-save event triggers save through the command store', async () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [{ id: '/root/doc.md', path: '/root/doc.md', title: 'doc.md', dirty: true }],
|
||||||
|
activeTabId: '/root/doc.md',
|
||||||
|
});
|
||||||
|
useEditorStore.setState({
|
||||||
|
buffers: new Map([['/root/doc.md', { id: '/root/doc.md', content: '# y', dirty: true }]]),
|
||||||
|
activeId: '/root/doc.md',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AppShell />);
|
||||||
|
await act(async () => {
|
||||||
|
fireMenu('file-save');
|
||||||
|
});
|
||||||
|
expect(useEditorStore.getState().buffers.get('/root/doc.md')?.dirty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('native menu toggle-preview event flips previewVisible', () => {
|
||||||
|
render(<AppShell />);
|
||||||
|
expect(useAppStore.getState().previewVisible).toBe(true);
|
||||||
|
act(() => {
|
||||||
|
fireMenu('toggle-preview');
|
||||||
|
});
|
||||||
|
expect(useAppStore.getState().previewVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('userBindings survive a remount via zustand persist', async () => {
|
||||||
|
useCommandStore.getState().setUserBinding('file.save', 'mod+shift+s');
|
||||||
|
await useCommandStore.persist?.flush?.();
|
||||||
|
|
||||||
|
// Read raw stored value
|
||||||
|
const stored = JSON.parse(localStorage.getItem('mc-command-store') ?? '{}');
|
||||||
|
expect(stored.state.userBindings['file.save']).toBe('mod+shift+s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commands registered in AppShell are accessible by id from any consumer', () => {
|
||||||
|
render(<AppShell />);
|
||||||
|
const handlers = useCommandStore.getState().handlers;
|
||||||
|
expect(typeof handlers['file.open']).toBe('function');
|
||||||
|
expect(typeof handlers['file.openFolder']).toBe('function');
|
||||||
|
expect(typeof handlers['file.save']).toBe('function');
|
||||||
|
expect(typeof handlers['file.closeTab']).toBe('function');
|
||||||
|
expect(typeof handlers['tab.next']).toBe('function');
|
||||||
|
expect(typeof handlers['tab.prev']).toBe('function');
|
||||||
|
expect(typeof handlers['view.toggleSidebar']).toBe('function');
|
||||||
|
expect(typeof handlers['view.togglePreview']).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, renderHook } from '@testing-library/react';
|
||||||
|
|
||||||
|
// Mock the IPC layer used by the file store actions.
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
file: {
|
||||||
|
open: vi.fn().mockResolvedValue({ ok: false, error: { code: 'NO_BRIDGE', message: 'mock' } }),
|
||||||
|
read: vi.fn().mockResolvedValue({ ok: false, error: { code: 'NO_BRIDGE', message: 'mock' } }),
|
||||||
|
write: vi.fn().mockResolvedValue({ ok: true, data: undefined }),
|
||||||
|
list: vi.fn().mockResolvedValue({ ok: true, data: [] }),
|
||||||
|
pickFolder: vi.fn().mockResolvedValue({ ok: true, data: null }),
|
||||||
|
pickFile: vi.fn().mockResolvedValue({ ok: true, data: null }),
|
||||||
|
onChange: vi.fn(() => () => {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useFileShortcuts } from '@/hooks/use-file-shortcuts';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
|
||||||
|
function fireKey(opts: KeyboardEventInit) {
|
||||||
|
fireEvent.keyDown(window, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useFileShortcuts', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset both stores
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: null,
|
||||||
|
rootPath: null,
|
||||||
|
expanded: new Set(),
|
||||||
|
openTabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
});
|
||||||
|
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Cmd+O triggers openFileDialog', () => {
|
||||||
|
renderHook(() => useFileShortcuts());
|
||||||
|
fireKey({ key: 'o', metaKey: true });
|
||||||
|
// We can't easily spy on the action, but the pickFile mock should have been called.
|
||||||
|
// The openFileDialog action awaits ipc.file.pickFile then early-returns on null result.
|
||||||
|
// Since we're using real timers, the action resolves; we just need to assert no throw.
|
||||||
|
// To verify dispatch, we spy on the store action below in another test.
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Cmd+Shift+O triggers openFolderDialog', () => {
|
||||||
|
renderHook(() => useFileShortcuts());
|
||||||
|
expect(() => fireKey({ key: 'O', metaKey: true, shiftKey: true })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+O (cross-platform) also dispatches', () => {
|
||||||
|
renderHook(() => useFileShortcuts());
|
||||||
|
expect(() => fireKey({ key: 'o', ctrlKey: true })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Cmd+S triggers saveActiveBuffer; no-op when no active tab', () => {
|
||||||
|
renderHook(() => useFileShortcuts());
|
||||||
|
fireKey({ key: 's', metaKey: true });
|
||||||
|
// No active tab, action returns false. No throw.
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Cmd+W with no active tab is a no-op', () => {
|
||||||
|
renderHook(() => useFileShortcuts());
|
||||||
|
expect(() => fireKey({ key: 'w', metaKey: true })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Cmd+W with active tab calls closeTab', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false }],
|
||||||
|
activeTabId: '/a.md',
|
||||||
|
});
|
||||||
|
renderHook(() => useFileShortcuts());
|
||||||
|
fireKey({ key: 'w', metaKey: true });
|
||||||
|
expect(useFileStore.getState().openTabs).toEqual([]);
|
||||||
|
expect(useFileStore.getState().activeTabId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Cmd+Tab advances to next tab (wraps to first)', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false },
|
||||||
|
{ id: '/b.md', path: '/b.md', title: 'b.md', dirty: false },
|
||||||
|
{ id: '/c.md', path: '/c.md', title: 'c.md', dirty: false },
|
||||||
|
],
|
||||||
|
activeTabId: '/c.md',
|
||||||
|
});
|
||||||
|
renderHook(() => useFileShortcuts());
|
||||||
|
fireKey({ key: 'Tab', metaKey: true });
|
||||||
|
expect(useFileStore.getState().activeTabId).toBe('/a.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Cmd+Shift+Tab goes to previous tab (wraps to last)', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false },
|
||||||
|
{ id: '/b.md', path: '/b.md', title: 'b.md', dirty: false },
|
||||||
|
{ id: '/c.md', path: '/c.md', title: 'c.md', dirty: false },
|
||||||
|
],
|
||||||
|
activeTabId: '/a.md',
|
||||||
|
});
|
||||||
|
renderHook(() => useFileShortcuts());
|
||||||
|
fireKey({ key: 'Tab', metaKey: true, shiftKey: true });
|
||||||
|
expect(useFileStore.getState().activeTabId).toBe('/c.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Cmd+Tab with no tabs is a no-op', () => {
|
||||||
|
renderHook(() => useFileShortcuts());
|
||||||
|
expect(() => fireKey({ key: 'Tab', metaKey: true })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses shortcuts when an <input> is focused', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false }],
|
||||||
|
activeTabId: '/a.md',
|
||||||
|
});
|
||||||
|
const { container } = render(
|
||||||
|
<div>
|
||||||
|
<input data-testid="t" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const input = container.querySelector('input')!;
|
||||||
|
renderHook(() => useFileShortcuts());
|
||||||
|
fireEvent.keyDown(input, { key: 'w', metaKey: true });
|
||||||
|
// Tab should still be open (shortcut suppressed)
|
||||||
|
expect(useFileStore.getState().openTabs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses shortcuts when a contentEditable element is focused', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false }],
|
||||||
|
activeTabId: '/a.md',
|
||||||
|
});
|
||||||
|
// Manually create an element with contentEditable=true and isContentEditable getter
|
||||||
|
const el = document.createElement('div');
|
||||||
|
Object.defineProperty(el, 'isContentEditable', { value: true, configurable: true });
|
||||||
|
document.body.appendChild(el);
|
||||||
|
try {
|
||||||
|
renderHook(() => useFileShortcuts());
|
||||||
|
fireEvent.keyDown(el, { key: 'w', metaKey: true });
|
||||||
|
expect(useFileStore.getState().openTabs).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plain keys without modifier are ignored', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false }],
|
||||||
|
activeTabId: '/a.md',
|
||||||
|
});
|
||||||
|
renderHook(() => useFileShortcuts());
|
||||||
|
fireKey({ key: 'o' });
|
||||||
|
fireKey({ key: 's' });
|
||||||
|
fireKey({ key: 'w' });
|
||||||
|
expect(useFileStore.getState().openTabs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleanup removes the listener on unmount', () => {
|
||||||
|
const { unmount } = renderHook(() => useFileShortcuts());
|
||||||
|
unmount();
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false }],
|
||||||
|
activeTabId: '/a.md',
|
||||||
|
});
|
||||||
|
fireKey({ key: 'w', metaKey: true });
|
||||||
|
expect(useFileStore.getState().openTabs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import { useMenuAction } from '@/hooks/use-menu-action';
|
||||||
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
|
|
||||||
|
type Cleanup = () => void;
|
||||||
|
|
||||||
|
const menuListeners = new Map<string, (...args: unknown[]) => void>();
|
||||||
|
const subscribeToMenu = vi.fn(
|
||||||
|
(channel: string, callback: (...args: unknown[]) => void): Cleanup => {
|
||||||
|
menuListeners.set(channel, callback);
|
||||||
|
return () => {
|
||||||
|
if (menuListeners.get(channel) === callback) menuListeners.delete(channel);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
menu: {
|
||||||
|
on: (channel: string, callback: (...args: unknown[]) => void) =>
|
||||||
|
subscribeToMenu(channel, callback),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function fireMenu(channel: string, ...args: unknown[]): void {
|
||||||
|
const listener = menuListeners.get(channel);
|
||||||
|
if (!listener) throw new Error(`No listener registered for ${channel}`);
|
||||||
|
listener(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useMenuAction', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
menuListeners.clear();
|
||||||
|
subscribeToMenu.mockClear();
|
||||||
|
useCommandStore.setState({ handlers: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subscribes to the given IPC channel and dispatches the matching command on fire', () => {
|
||||||
|
const dispatched: unknown[] = [];
|
||||||
|
useCommandStore.getState().register('file.save', (args) => dispatched.push(args));
|
||||||
|
|
||||||
|
renderHook(() => useMenuAction('file-save', 'file.save'));
|
||||||
|
|
||||||
|
expect(subscribeToMenu).toHaveBeenCalledWith('file-save', expect.any(Function));
|
||||||
|
fireMenu('file-save');
|
||||||
|
expect(dispatched).toEqual([undefined]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards the IPC payload to the command as args', () => {
|
||||||
|
const dispatched: unknown[] = [];
|
||||||
|
useCommandStore.getState().register('template.load', (args) => dispatched.push(args));
|
||||||
|
|
||||||
|
renderHook(() => useMenuAction('load-template-menu', 'template.load', (name) => ({ name })));
|
||||||
|
|
||||||
|
fireMenu('load-template-menu', 'blog-post.md');
|
||||||
|
expect(dispatched).toEqual([{ name: 'blog-post.md' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('default transform: args is the first IPC payload as-is', () => {
|
||||||
|
const dispatched: unknown[] = [];
|
||||||
|
useCommandStore.getState().register('file.opened', (args) => dispatched.push(args));
|
||||||
|
|
||||||
|
renderHook(() => useMenuAction('file-opened', 'file.opened'));
|
||||||
|
|
||||||
|
fireMenu('file-opened', { path: '/a.md', content: '# hi' });
|
||||||
|
expect(dispatched).toEqual([{ path: '/a.md', content: '# hi' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('if no command is registered, fire is a no-op (does not throw)', () => {
|
||||||
|
renderHook(() => useMenuAction('file-save', 'file.save'));
|
||||||
|
expect(() => fireMenu('file-save')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unsubscribes on unmount', () => {
|
||||||
|
renderHook(() => useMenuAction('file-save', 'file.save'));
|
||||||
|
expect(menuListeners.has('file-save')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import { fireEvent } from '@testing-library/dom';
|
||||||
|
import { useShortcut } from '@/hooks/use-shortcut';
|
||||||
|
|
||||||
|
describe('useShortcut', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// No-op: each test sets up its own keydown
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls the callback when the shortcut matches (mod+s)', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
renderHook(() => useShortcut('mod+s', cb));
|
||||||
|
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
|
||||||
|
expect(cb).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes metaKey as well as ctrlKey (Mac shortcut)', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
renderHook(() => useShortcut('mod+s', cb));
|
||||||
|
fireEvent.keyDown(window, { key: 's', metaKey: true });
|
||||||
|
expect(cb).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches plain keys without modifier (just "k")', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
renderHook(() => useShortcut('k', cb));
|
||||||
|
fireEvent.keyDown(window, { key: 'k' });
|
||||||
|
expect(cb).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches shift variants (mod+shift+s)', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
renderHook(() => useShortcut('mod+shift+s', cb));
|
||||||
|
fireEvent.keyDown(window, { key: 'S', ctrlKey: true, shiftKey: true });
|
||||||
|
expect(cb).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT fire when shift is required but missing', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
renderHook(() => useShortcut('mod+shift+s', cb));
|
||||||
|
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT fire when the wrong key is pressed', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
renderHook(() => useShortcut('mod+s', cb));
|
||||||
|
fireEvent.keyDown(window, { key: 'x', ctrlKey: true });
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT fire when no modifier is held but one is required', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
renderHook(() => useShortcut('mod+s', cb));
|
||||||
|
fireEvent.keyDown(window, { key: 's' });
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT fire when the target is an <input>', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
renderHook(() => useShortcut('mod+s', cb));
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
fireEvent.keyDown(input, { key: 's', ctrlKey: true });
|
||||||
|
document.body.removeChild(input);
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT fire when the target is a <textarea>', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
renderHook(() => useShortcut('mod+s', cb));
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
fireEvent.keyDown(ta, { key: 's', ctrlKey: true });
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT fire when the target is contentEditable', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
renderHook(() => useShortcut('mod+s', cb));
|
||||||
|
const div = document.createElement('div');
|
||||||
|
Object.defineProperty(div, 'isContentEditable', { value: true, configurable: true });
|
||||||
|
document.body.appendChild(div);
|
||||||
|
fireEvent.keyDown(div, { key: 's', ctrlKey: true });
|
||||||
|
document.body.removeChild(div);
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls preventDefault when the shortcut matches', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
renderHook(() => useShortcut('mod+s', cb));
|
||||||
|
const event = new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true, cancelable: true });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT call preventDefault when the shortcut does NOT match', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
renderHook(() => useShortcut('mod+s', cb));
|
||||||
|
const event = new KeyboardEvent('keydown', { key: 'x', ctrlKey: true, bubbles: true, cancelable: true });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
expect(event.defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports the special "Tab" key as a non-modifier shortcut', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
renderHook(() => useShortcut('mod+tab', cb));
|
||||||
|
fireEvent.keyDown(window, { key: 'Tab', ctrlKey: true });
|
||||||
|
expect(cb).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unbinds the listener on unmount', () => {
|
||||||
|
const cb = vi.fn();
|
||||||
|
const { unmount } = renderHook(() => useShortcut('mod+s', cb));
|
||||||
|
unmount();
|
||||||
|
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
|
||||||
|
expect(cb).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the bound callback when the function reference changes', () => {
|
||||||
|
const a = vi.fn();
|
||||||
|
const b = vi.fn();
|
||||||
|
const { rerender } = renderHook(({ cb }) => useShortcut('mod+s', cb), {
|
||||||
|
initialProps: { cb: a },
|
||||||
|
});
|
||||||
|
rerender({ cb: b });
|
||||||
|
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
|
||||||
|
expect(a).not.toHaveBeenCalled();
|
||||||
|
expect(b).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, act } from '@testing-library/react';
|
||||||
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
|
import { useFileStore } from '@/stores/file-store';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { useRegisterMenuCommands, useBridgeNativeMenu } from '@/lib/commands/register-menu-commands';
|
||||||
|
|
||||||
|
type Cleanup = () => void;
|
||||||
|
const menuListeners = new Map<string, (...args: unknown[]) => void>();
|
||||||
|
const menuOn = vi.fn(
|
||||||
|
(channel: string, callback: (...args: unknown[]) => void): Cleanup => {
|
||||||
|
menuListeners.set(channel, callback);
|
||||||
|
return () => {
|
||||||
|
if (menuListeners.get(channel) === callback) menuListeners.delete(channel);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
menu: {
|
||||||
|
on: (channel: string, cb: (...args: unknown[]) => void) => menuOn(channel, cb),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function fireMenu(channel: string, ...args: unknown[]): void {
|
||||||
|
const listener = menuListeners.get(channel);
|
||||||
|
if (!listener) throw new Error(`No listener for ${channel}`);
|
||||||
|
listener(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
useRegisterMenuCommands();
|
||||||
|
useBridgeNativeMenu();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useRegisterMenuCommands + useBridgeNativeMenu', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
menuListeners.clear();
|
||||||
|
menuOn.mockClear();
|
||||||
|
useCommandStore.setState({ handlers: {} });
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: null,
|
||||||
|
rootPath: null,
|
||||||
|
expanded: new Set(),
|
||||||
|
openTabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
});
|
||||||
|
useAppStore.setState({
|
||||||
|
sidebarVisible: true,
|
||||||
|
previewVisible: true,
|
||||||
|
zenMode: false,
|
||||||
|
paneSizes: { sidebar: 20, editor: 50, preview: 30 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers file.open, file.save, file.closeTab handlers in the command store', () => {
|
||||||
|
render(<Harness />);
|
||||||
|
const handlers = useCommandStore.getState().handlers;
|
||||||
|
expect(typeof handlers['file.open']).toBe('function');
|
||||||
|
expect(typeof handlers['file.save']).toBe('function');
|
||||||
|
expect(typeof handlers['file.closeTab']).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers tab.next / tab.prev and view.toggle* commands', () => {
|
||||||
|
render(<Harness />);
|
||||||
|
const handlers = useCommandStore.getState().handlers;
|
||||||
|
expect(typeof handlers['tab.next']).toBe('function');
|
||||||
|
expect(typeof handlers['tab.prev']).toBe('function');
|
||||||
|
expect(typeof handlers['view.toggleSidebar']).toBe('function');
|
||||||
|
expect(typeof handlers['view.togglePreview']).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('view.togglePreview command flips previewVisible', () => {
|
||||||
|
render(<Harness />);
|
||||||
|
expect(useAppStore.getState().previewVisible).toBe(true);
|
||||||
|
act(() => useCommandStore.getState().dispatch('view.togglePreview'));
|
||||||
|
expect(useAppStore.getState().previewVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('view.toggleSidebar command flips sidebarVisible', () => {
|
||||||
|
render(<Harness />);
|
||||||
|
expect(useAppStore.getState().sidebarVisible).toBe(true);
|
||||||
|
act(() => useCommandStore.getState().dispatch('view.toggleSidebar'));
|
||||||
|
expect(useAppStore.getState().sidebarVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('file.closeTab with no active tab is a no-op (does not throw)', () => {
|
||||||
|
render(<Harness />);
|
||||||
|
expect(() => act(() => useCommandStore.getState().dispatch('file.closeTab'))).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tab.next wraps from last tab to first', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false },
|
||||||
|
{ id: '/b.md', path: '/b.md', title: 'b.md', dirty: false },
|
||||||
|
],
|
||||||
|
activeTabId: '/b.md',
|
||||||
|
});
|
||||||
|
render(<Harness />);
|
||||||
|
act(() => useCommandStore.getState().dispatch('tab.next'));
|
||||||
|
expect(useFileStore.getState().activeTabId).toBe('/a.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tab.prev wraps from first tab to last', () => {
|
||||||
|
useFileStore.setState({
|
||||||
|
openTabs: [
|
||||||
|
{ id: '/a.md', path: '/a.md', title: 'a.md', dirty: false },
|
||||||
|
{ id: '/b.md', path: '/b.md', title: 'b.md', dirty: false },
|
||||||
|
],
|
||||||
|
activeTabId: '/a.md',
|
||||||
|
});
|
||||||
|
render(<Harness />);
|
||||||
|
act(() => useCommandStore.getState().dispatch('tab.prev'));
|
||||||
|
expect(useFileStore.getState().activeTabId).toBe('/b.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('file-save IPC event dispatches file.save command', () => {
|
||||||
|
render(<Harness />);
|
||||||
|
act(() => fireMenu('file-save'));
|
||||||
|
// No assertion on side effect (saveActiveBuffer is async) but
|
||||||
|
// we can verify the command ran by checking no error was thrown.
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggle-preview IPC event flips previewVisible via the command', () => {
|
||||||
|
render(<Harness />);
|
||||||
|
expect(useAppStore.getState().previewVisible).toBe(true);
|
||||||
|
act(() => fireMenu('toggle-preview'));
|
||||||
|
expect(useAppStore.getState().previewVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('load-template-menu IPC event forwards the template name as args', () => {
|
||||||
|
let captured: unknown;
|
||||||
|
useCommandStore.getState().register('template.load', (args) => {
|
||||||
|
captured = args;
|
||||||
|
});
|
||||||
|
render(<Harness />);
|
||||||
|
act(() => fireMenu('load-template-menu', 'blog-post.md'));
|
||||||
|
expect(captured).toBe('blog-post.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { useCommandStore } from '@/stores/command-store';
|
||||||
|
|
||||||
|
describe('useCommandStore — user bindings persistence', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
useCommandStore.setState({
|
||||||
|
handlers: {},
|
||||||
|
userBindings: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with empty userBindings', () => {
|
||||||
|
expect(useCommandStore.getState().userBindings).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setUserBinding stores a combo for a command id', () => {
|
||||||
|
useCommandStore.getState().setUserBinding('file.save', 'mod+shift+s');
|
||||||
|
expect(useCommandStore.getState().userBindings['file.save']).toBe('mod+shift+s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setUserBinding overwrites an existing binding for the same command', () => {
|
||||||
|
useCommandStore.getState().setUserBinding('file.save', 'mod+shift+s');
|
||||||
|
useCommandStore.getState().setUserBinding('file.save', 'mod+alt+s');
|
||||||
|
expect(useCommandStore.getState().userBindings['file.save']).toBe('mod+alt+s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearUserBinding removes a binding', () => {
|
||||||
|
useCommandStore.getState().setUserBinding('file.save', 'mod+shift+s');
|
||||||
|
useCommandStore.getState().clearUserBinding('file.save');
|
||||||
|
expect(useCommandStore.getState().userBindings['file.save']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearUserBinding on a missing id is a no-op', () => {
|
||||||
|
expect(() => useCommandStore.getState().clearUserBinding('does.not.exist')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getUserBinding returns the combo or undefined', () => {
|
||||||
|
useCommandStore.getState().setUserBinding('file.save', 'mod+shift+s');
|
||||||
|
expect(useCommandStore.getState().getUserBinding('file.save')).toBe('mod+shift+s');
|
||||||
|
expect(useCommandStore.getState().getUserBinding('does.not.exist')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists userBindings to localStorage and restores on next mount', async () => {
|
||||||
|
useCommandStore.getState().setUserBinding('file.save', 'mod+shift+s');
|
||||||
|
useCommandStore.getState().setUserBinding('view.toggleSidebar', 'mod+b');
|
||||||
|
|
||||||
|
// Force the persist middleware to flush
|
||||||
|
await useCommandStore.persist?.flush?.();
|
||||||
|
|
||||||
|
const stored = JSON.parse(localStorage.getItem('mc-command-store') ?? '{}');
|
||||||
|
expect(stored.state.userBindings).toEqual({
|
||||||
|
'file.save': 'mod+shift+s',
|
||||||
|
'view.toggleSidebar': 'mod+b',
|
||||||
|
});
|
||||||
|
// Handlers must NOT be persisted (functions aren't serializable)
|
||||||
|
expect(stored.state.handlers).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { useCommandStore, CommandHandler } from '@/stores/command-store';
|
||||||
|
|
||||||
|
describe('useCommandStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useCommandStore.setState({ handlers: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('register stores a handler keyed by id', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
useCommandStore.getState().register('file.open', handler);
|
||||||
|
expect(useCommandStore.getState().handlers['file.open']).toBe(handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('register overwrites any existing handler for the same id', () => {
|
||||||
|
const a = vi.fn();
|
||||||
|
const b = vi.fn();
|
||||||
|
useCommandStore.getState().register('file.open', a);
|
||||||
|
useCommandStore.getState().register('file.open', b);
|
||||||
|
expect(useCommandStore.getState().handlers['file.open']).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatch invokes the registered handler with the provided args', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
useCommandStore.getState().register('file.save', handler);
|
||||||
|
useCommandStore.getState().dispatch('file.save', { path: '/x.md' });
|
||||||
|
expect(handler).toHaveBeenCalledWith({ path: '/x.md' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatch with no args calls the handler with undefined', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
useCommandStore.getState().register('view.toggleSidebar', handler);
|
||||||
|
useCommandStore.getState().dispatch('view.toggleSidebar');
|
||||||
|
expect(handler).toHaveBeenCalledWith(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatch on an unregistered id is a no-op (no throw)', () => {
|
||||||
|
expect(() => useCommandStore.getState().dispatch('does.not.exist')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unregister removes a handler', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
useCommandStore.getState().register('file.open', handler);
|
||||||
|
useCommandStore.getState().unregister('file.open');
|
||||||
|
expect(useCommandStore.getState().handlers['file.open']).toBeUndefined();
|
||||||
|
useCommandStore.getState().dispatch('file.open');
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unregister on a missing id is a no-op', () => {
|
||||||
|
expect(() => useCommandStore.getState().unregister('does.not.exist')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('get returns the handler or undefined', () => {
|
||||||
|
const handler: CommandHandler = () => {};
|
||||||
|
useCommandStore.getState().register('file.open', handler);
|
||||||
|
expect(useCommandStore.getState().get('file.open')).toBe(handler);
|
||||||
|
expect(useCommandStore.getState().get('does.not.exist')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registerMany registers multiple handlers in one call', () => {
|
||||||
|
const a = vi.fn();
|
||||||
|
const b = vi.fn();
|
||||||
|
useCommandStore.getState().registerMany({
|
||||||
|
'file.open': a,
|
||||||
|
'file.save': b,
|
||||||
|
});
|
||||||
|
expect(useCommandStore.getState().handlers['file.open']).toBe(a);
|
||||||
|
expect(useCommandStore.getState().handlers['file.save']).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registerMany does not clear handlers that were already registered', () => {
|
||||||
|
const existing = vi.fn();
|
||||||
|
const added = vi.fn();
|
||||||
|
useCommandStore.getState().register('file.open', existing);
|
||||||
|
useCommandStore.getState().registerMany({ 'file.save': added });
|
||||||
|
expect(useCommandStore.getState().handlers['file.open']).toBe(existing);
|
||||||
|
expect(useCommandStore.getState().handlers['file.save']).toBe(added);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
|
import { useFileStore, FileNode, OpenTab } from '@/stores/file-store';
|
||||||
|
import type { FileEntry } from '@/types/ipc';
|
||||||
|
|
||||||
|
vi.mock('@/lib/ipc', () => ({
|
||||||
|
ipc: {
|
||||||
|
file: {
|
||||||
|
list: vi.fn(),
|
||||||
|
read: vi.fn(),
|
||||||
|
pickFolder: vi.fn(),
|
||||||
|
pickFile: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ipc } from '@/lib/ipc';
|
||||||
|
|
||||||
|
const fakeList = ipc.file.list as ReturnType<typeof vi.fn>;
|
||||||
|
const fakeRead = ipc.file.read as ReturnType<typeof vi.fn>;
|
||||||
|
const fakePickFolder = ipc.file.pickFolder as ReturnType<typeof vi.fn>;
|
||||||
|
const fakePickFile = ipc.file.pickFile as ReturnType<typeof vi.fn>;
|
||||||
|
const fakeWrite = ipc.file.write as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
function fakeFileEntry(name: string, isDirectory: boolean): FileEntry {
|
||||||
|
return { name, path: `/root/${name}`, isDirectory };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useFileStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useFileStore.setState({
|
||||||
|
tree: null,
|
||||||
|
rootPath: null,
|
||||||
|
expanded: new Set(),
|
||||||
|
openTabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
});
|
||||||
|
useEditorStore.setState({ buffers: new Map(), activeId: null });
|
||||||
|
fakeList.mockClear();
|
||||||
|
fakeRead.mockClear();
|
||||||
|
fakePickFolder.mockClear();
|
||||||
|
fakePickFile.mockClear();
|
||||||
|
fakeWrite.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- openFolder ---
|
||||||
|
|
||||||
|
it('openFolder calls ipc.file.list and sets tree with root node and mapped children', async () => {
|
||||||
|
fakeList.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
data: [
|
||||||
|
fakeFileEntry('README.md', false),
|
||||||
|
fakeFileEntry('src', true),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await useFileStore.getState().openFolder('/root');
|
||||||
|
|
||||||
|
expect(fakeList).toHaveBeenCalledWith('/root');
|
||||||
|
const tree = useFileStore.getState().tree;
|
||||||
|
expect(tree).not.toBeNull();
|
||||||
|
expect(tree!.name).toBe('root');
|
||||||
|
expect(tree!.path).toBe('/root');
|
||||||
|
expect(tree!.isDirectory).toBe(true);
|
||||||
|
expect(tree!.loaded).toBe(true);
|
||||||
|
expect(tree!.children).toHaveLength(2);
|
||||||
|
expect(tree!.children![0].name).toBe('README.md');
|
||||||
|
expect(tree!.children![0].isDirectory).toBe(false);
|
||||||
|
expect(tree!.children![1].name).toBe('src');
|
||||||
|
expect(tree!.children![1].isDirectory).toBe(true);
|
||||||
|
// children of a directory node should be null (lazy)
|
||||||
|
expect(tree!.children![1].children).toBeNull();
|
||||||
|
expect(useFileStore.getState().rootPath).toBe('/root');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('openFolder failure leaves state unchanged', async () => {
|
||||||
|
fakeList.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'ENOENT', message: 'not found' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await useFileStore.getState().openFolder('/nonexistent');
|
||||||
|
expect(useFileStore.getState().tree).toBeNull();
|
||||||
|
expect(useFileStore.getState().rootPath).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- loadChildren ---
|
||||||
|
|
||||||
|
it('loadChildren IPC-loads children of the matching directory and sets loaded: true', async () => {
|
||||||
|
fakeList.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
data: [fakeFileEntry('nested.md', false)],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up a tree with a loaded parent directory
|
||||||
|
const tree: FileNode = {
|
||||||
|
name: 'root',
|
||||||
|
path: '/root',
|
||||||
|
isDirectory: true,
|
||||||
|
loaded: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'src',
|
||||||
|
path: '/root/src',
|
||||||
|
isDirectory: true,
|
||||||
|
children: null,
|
||||||
|
loaded: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
useFileStore.setState({ tree, rootPath: '/root' });
|
||||||
|
|
||||||
|
await useFileStore.getState().loadChildren('/root/src');
|
||||||
|
|
||||||
|
const srcNode = useFileStore.getState().tree!.children![0];
|
||||||
|
expect(srcNode.loaded).toBe(true);
|
||||||
|
expect(srcNode.children).toHaveLength(1);
|
||||||
|
expect(srcNode.children![0].name).toBe('nested.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadChildren on a non-directory node is a no-op', async () => {
|
||||||
|
const tree: FileNode = {
|
||||||
|
name: 'root',
|
||||||
|
path: '/root',
|
||||||
|
isDirectory: true,
|
||||||
|
loaded: true,
|
||||||
|
children: [{ name: 'file.md', path: '/root/file.md', isDirectory: false, children: null }],
|
||||||
|
};
|
||||||
|
useFileStore.setState({ tree });
|
||||||
|
|
||||||
|
await useFileStore.getState().loadChildren('/root/file.md');
|
||||||
|
expect(fakeList).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadChildren on a non-existent path is a no-op', async () => {
|
||||||
|
const tree: FileNode = {
|
||||||
|
name: 'root',
|
||||||
|
path: '/root',
|
||||||
|
isDirectory: true,
|
||||||
|
loaded: true,
|
||||||
|
children: [{ name: 'src', path: '/root/src', isDirectory: true, children: null, loaded: true }],
|
||||||
|
};
|
||||||
|
useFileStore.setState({ tree });
|
||||||
|
|
||||||
|
await useFileStore.getState().loadChildren('/root/src/not-there');
|
||||||
|
// Should not throw
|
||||||
|
expect(fakeList).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadChildren is idempotent: does not re-fetch when already loaded', async () => {
|
||||||
|
fakeList.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
data: [{ name: 'src', path: '/root/src', isDirectory: true }],
|
||||||
|
});
|
||||||
|
await useFileStore.getState().openFolder('/root');
|
||||||
|
|
||||||
|
fakeList.mockClear();
|
||||||
|
fakeList.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
data: [{ name: 'foo.ts', path: '/root/src/foo.ts', isDirectory: false }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await useFileStore.getState().loadChildren('/root/src');
|
||||||
|
const callsAfterFirst = fakeList.mock.calls.length;
|
||||||
|
expect(callsAfterFirst).toBe(1);
|
||||||
|
|
||||||
|
// Second call should be a no-op (loaded is true)
|
||||||
|
await useFileStore.getState().loadChildren('/root/src');
|
||||||
|
expect(fakeList.mock.calls.length).toBe(callsAfterFirst);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- toggleExpanded ---
|
||||||
|
|
||||||
|
it('toggleExpanded adds and removes paths from the expanded Set', () => {
|
||||||
|
const { toggleExpanded } = useFileStore.getState();
|
||||||
|
toggleExpanded('/root/src');
|
||||||
|
expect(useFileStore.getState().expanded.has('/root/src')).toBe(true);
|
||||||
|
toggleExpanded('/root/src');
|
||||||
|
expect(useFileStore.getState().expanded.has('/root/src')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- openFile ---
|
||||||
|
|
||||||
|
it('openFile calls ipc.file.read, opens an editor buffer, adds a tab, and sets it active', async () => {
|
||||||
|
fakeRead.mockResolvedValue({ ok: true, data: '# hello world' });
|
||||||
|
|
||||||
|
await useFileStore.getState().openFile('/root/README.md');
|
||||||
|
|
||||||
|
expect(fakeRead).toHaveBeenCalledWith('/root/README.md');
|
||||||
|
// Editor buffer should be open
|
||||||
|
const buf = useEditorStore.getState().buffers.get('/root/README.md');
|
||||||
|
expect(buf).not.toBeUndefined();
|
||||||
|
expect(buf!.content).toBe('# hello world');
|
||||||
|
expect(buf!.dirty).toBe(false);
|
||||||
|
// Tab should be added and active
|
||||||
|
const tabs = useFileStore.getState().openTabs;
|
||||||
|
expect(tabs).toHaveLength(1);
|
||||||
|
expect(tabs[0].path).toBe('/root/README.md');
|
||||||
|
expect(tabs[0].title).toBe('README.md');
|
||||||
|
expect(tabs[0].dirty).toBe(false);
|
||||||
|
expect(useFileStore.getState().activeTabId).toBe('/root/README.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('openFile on an already-open file does not re-read — just activates the existing tab', async () => {
|
||||||
|
fakeRead.mockResolvedValue({ ok: true, data: '# original' });
|
||||||
|
|
||||||
|
// Open once
|
||||||
|
await useFileStore.getState().openFile('/root/README.md');
|
||||||
|
expect(fakeRead).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Mark it dirty to prove dirty flag is preserved
|
||||||
|
useEditorStore.getState().updateContent('/root/README.md', '# modified');
|
||||||
|
|
||||||
|
// Open again
|
||||||
|
await useFileStore.getState().openFile('/root/README.md');
|
||||||
|
|
||||||
|
expect(fakeRead).toHaveBeenCalledTimes(1); // no second read
|
||||||
|
expect(useEditorStore.getState().buffers.get('/root/README.md')!.content).toBe('# modified');
|
||||||
|
expect(useFileStore.getState().openTabs[0].dirty).toBe(true); // dirty flag preserved
|
||||||
|
expect(useFileStore.getState().activeTabId).toBe('/root/README.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('openFile failure does nothing', async () => {
|
||||||
|
fakeRead.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'ENOENT', message: 'not found' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await useFileStore.getState().openFile('/root/missing.md');
|
||||||
|
expect(useFileStore.getState().openTabs).toHaveLength(0);
|
||||||
|
expect(useEditorStore.getState().buffers.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- closeTab ---
|
||||||
|
|
||||||
|
it('closeTab removes the tab and updates activeTabId to the previous neighbor', async () => {
|
||||||
|
fakeRead.mockResolvedValue({ ok: true, data: 'a' });
|
||||||
|
fakeRead.mockResolvedValue({ ok: true, data: 'b' });
|
||||||
|
|
||||||
|
await useFileStore.getState().openFile('/root/a.md');
|
||||||
|
await useFileStore.getState().openFile('/root/b.md');
|
||||||
|
|
||||||
|
// Tab order: [a.md, b.md], active = b.md
|
||||||
|
useFileStore.getState().closeTab('/root/b.md');
|
||||||
|
|
||||||
|
const tabs = useFileStore.getState().openTabs;
|
||||||
|
expect(tabs).toHaveLength(1);
|
||||||
|
expect(tabs[0].id).toBe('/root/a.md');
|
||||||
|
expect(useFileStore.getState().activeTabId).toBe('/root/a.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closeTab on the active tab when it is the only tab sets activeTabId to null', async () => {
|
||||||
|
fakeRead.mockResolvedValue({ ok: true, data: '# only' });
|
||||||
|
await useFileStore.getState().openFile('/root/only.md');
|
||||||
|
|
||||||
|
useFileStore.getState().closeTab('/root/only.md');
|
||||||
|
|
||||||
|
expect(useFileStore.getState().openTabs).toHaveLength(0);
|
||||||
|
expect(useFileStore.getState().activeTabId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- setActiveTab ---
|
||||||
|
|
||||||
|
it('setActiveTab updates activeTabId', async () => {
|
||||||
|
fakeRead.mockResolvedValue({ ok: true, data: 'a' });
|
||||||
|
fakeRead.mockResolvedValue({ ok: true, data: 'b' });
|
||||||
|
|
||||||
|
await useFileStore.getState().openFile('/root/a.md');
|
||||||
|
await useFileStore.getState().openFile('/root/b.md');
|
||||||
|
useFileStore.getState().setActiveTab('/root/a.md');
|
||||||
|
|
||||||
|
expect(useFileStore.getState().activeTabId).toBe('/root/a.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- markTabClean / markTabDirty ---
|
||||||
|
|
||||||
|
it('markTabClean sets dirty to false on the matching tab', async () => {
|
||||||
|
fakeRead.mockResolvedValue({ ok: true, data: '# doc' });
|
||||||
|
await useFileStore.getState().openFile('/root/doc.md');
|
||||||
|
useEditorStore.getState().updateContent('/root/doc.md', '# changed');
|
||||||
|
|
||||||
|
useFileStore.getState().markTabClean('/root/doc.md');
|
||||||
|
|
||||||
|
expect(useFileStore.getState().openTabs[0].dirty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markTabDirty sets dirty to true on the matching tab', async () => {
|
||||||
|
fakeRead.mockResolvedValue({ ok: true, data: '# doc' });
|
||||||
|
await useFileStore.getState().openFile('/root/doc.md');
|
||||||
|
|
||||||
|
useFileStore.getState().markTabDirty('/root/doc.md');
|
||||||
|
|
||||||
|
expect(useFileStore.getState().openTabs[0].dirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- reorderTabs ---
|
||||||
|
|
||||||
|
it('reorderTabs moves a tab from one index to another', async () => {
|
||||||
|
fakeRead.mockResolvedValue({ ok: true, data: 'a' });
|
||||||
|
fakeRead.mockResolvedValue({ ok: true, data: 'b' });
|
||||||
|
fakeRead.mockResolvedValue({ ok: true, data: 'c' });
|
||||||
|
|
||||||
|
await useFileStore.getState().openFile('/root/a.md');
|
||||||
|
await useFileStore.getState().openFile('/root/b.md');
|
||||||
|
await useFileStore.getState().openFile('/root/c.md');
|
||||||
|
|
||||||
|
// [a, b, c] — move c to front
|
||||||
|
useFileStore.getState().reorderTabs(2, 0);
|
||||||
|
|
||||||
|
const tabs = useFileStore.getState().openTabs;
|
||||||
|
expect(tabs.map((t) => t.title)).toEqual(['c.md', 'a.md', 'b.md']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- openFolderDialog ---
|
||||||
|
|
||||||
|
it('openFolderDialog calls pickFolder, then openFolder on the returned path', async () => {
|
||||||
|
fakePickFolder.mockResolvedValue({ ok: true, data: '/projects/myapp' });
|
||||||
|
fakeList.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
data: [fakeFileEntry('README.md', false)],
|
||||||
|
});
|
||||||
|
|
||||||
|
await useFileStore.getState().openFolderDialog();
|
||||||
|
|
||||||
|
expect(fakePickFolder).toHaveBeenCalled();
|
||||||
|
expect(fakeList).toHaveBeenCalledWith('/projects/myapp');
|
||||||
|
expect(useFileStore.getState().rootPath).toBe('/projects/myapp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('openFolderDialog on cancelled (null) is a no-op', async () => {
|
||||||
|
fakePickFolder.mockResolvedValue({ ok: true, data: null });
|
||||||
|
|
||||||
|
await useFileStore.getState().openFolderDialog();
|
||||||
|
|
||||||
|
expect(fakeList).not.toHaveBeenCalled();
|
||||||
|
expect(useFileStore.getState().tree).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- openFileDialog ---
|
||||||
|
|
||||||
|
it('openFileDialog calls pickFile, then openFile on the returned path', async () => {
|
||||||
|
fakePickFile.mockResolvedValue({ ok: true, data: '/root/README.md' });
|
||||||
|
fakeRead.mockResolvedValue({ ok: true, data: '# hello' });
|
||||||
|
|
||||||
|
await useFileStore.getState().openFileDialog();
|
||||||
|
|
||||||
|
expect(fakePickFile).toHaveBeenCalled();
|
||||||
|
expect(fakeRead).toHaveBeenCalledWith('/root/README.md');
|
||||||
|
const tabs = useFileStore.getState().openTabs;
|
||||||
|
expect(tabs).toHaveLength(1);
|
||||||
|
expect(tabs[0].path).toBe('/root/README.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('openFileDialog on cancelled (null) is a no-op', async () => {
|
||||||
|
fakePickFile.mockResolvedValue({ ok: true, data: null });
|
||||||
|
|
||||||
|
await useFileStore.getState().openFileDialog();
|
||||||
|
|
||||||
|
expect(fakeRead).not.toHaveBeenCalled();
|
||||||
|
expect(useFileStore.getState().openTabs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- saveActiveBuffer ---
|
||||||
|
|
||||||
|
it('saveActiveBuffer with no active tab returns false', async () => {
|
||||||
|
const result = await useFileStore.getState().saveActiveBuffer();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(fakeWrite).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saveActiveBuffer with active buffer calls write, then marks saved and clean, returns true', async () => {
|
||||||
|
fakeRead.mockResolvedValue({ ok: true, data: '# content' });
|
||||||
|
await useFileStore.getState().openFile('/root/doc.md');
|
||||||
|
fakeWrite.mockResolvedValue({ ok: true, data: undefined });
|
||||||
|
|
||||||
|
const result = await useFileStore.getState().saveActiveBuffer();
|
||||||
|
|
||||||
|
expect(fakeWrite).toHaveBeenCalledWith('/root/doc.md', '# content');
|
||||||
|
expect(useEditorStore.getState().buffers.get('/root/doc.md')!.dirty).toBe(false);
|
||||||
|
expect(useFileStore.getState().openTabs[0].dirty).toBe(false);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saveActiveBuffer on write failure returns false and does NOT mark clean', async () => {
|
||||||
|
fakeRead.mockResolvedValue({ ok: true, data: '# content' });
|
||||||
|
await useFileStore.getState().openFile('/root/doc.md');
|
||||||
|
// Mark the buffer dirty and sync the tab dirty flag before attempting save
|
||||||
|
useEditorStore.getState().updateContent('/root/doc.md', '# modified');
|
||||||
|
useFileStore.getState().markTabDirty('/root/doc.md');
|
||||||
|
fakeWrite.mockResolvedValue({ ok: false, error: { code: 'EIO', message: 'write error' } });
|
||||||
|
|
||||||
|
const result = await useFileStore.getState().saveActiveBuffer();
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(useEditorStore.getState().buffers.get('/root/doc.md')!.dirty).toBe(true);
|
||||||
|
expect(useFileStore.getState().openTabs[0].dirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- persistence ---
|
||||||
|
|
||||||
|
it('persists rootPath to localStorage and restores on next mount', async () => {
|
||||||
|
fakeList.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
data: [{ name: 'a.md', path: '/root/a.md', isDirectory: false }],
|
||||||
|
});
|
||||||
|
await useFileStore.getState().openFolder('/root');
|
||||||
|
|
||||||
|
// Force the persist middleware to flush to localStorage
|
||||||
|
await useFileStore.persist?.flush?.();
|
||||||
|
|
||||||
|
const stored = JSON.parse(localStorage.getItem('mc-file-store') ?? '{}');
|
||||||
|
expect(stored.state.rootPath).toBe('/root');
|
||||||
|
// Ensure non-persisted fields are NOT in storage
|
||||||
|
expect(stored.state.tree).toBeUndefined();
|
||||||
|
expect(stored.state.openTabs).toBeUndefined();
|
||||||
|
expect(stored.state.expanded).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user