app.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. import React, { useState, useEffect, useLayoutEffect } from 'react';
  2. import { Box, Text, useApp, useInput, useStdout } from 'ink';
  3. import { Header } from './components/Header.js';
  4. import { MarkdownView } from './components/MarkdownView.js';
  5. import { StatusBar } from './components/StatusBar.js';
  6. import { useFileWatcher } from './hooks/useFileWatcher.js';
  7. import { useDirectoryFiles } from './hooks/useDirectoryFiles.js';
  8. import { readMeta, type CanvasMeta } from './lib/ipc.js';
  9. import { editInExternalEditor } from './lib/editor.js';
  10. interface AppProps {
  11. watchPath: string;
  12. watchDir: string;
  13. enableMouse?: boolean;
  14. }
  15. // ANSI escape sequences for mouse support
  16. const ENABLE_MOUSE = '\x1B[?1000h\x1B[?1002h\x1B[?1006h';
  17. const DISABLE_MOUSE = '\x1B[?1000l\x1B[?1002l\x1B[?1006l';
  18. export const App: React.FC<AppProps> = ({ watchPath, watchDir, enableMouse = true }) => {
  19. const { exit } = useApp();
  20. const { stdout } = useStdout();
  21. const [content, setContent] = useState<string>('');
  22. const [meta, setMeta] = useState<CanvasMeta | null>(null);
  23. const [syncStatus, setSyncStatus] = useState<'waiting' | 'synced' | 'watching'>('waiting');
  24. const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
  25. const [scrollOffset, setScrollOffset] = useState(0);
  26. const [totalLines, setTotalLines] = useState(0);
  27. const [mouseEnabled, setMouseEnabled] = useState(enableMouse);
  28. const [isEditing, setIsEditing] = useState(false);
  29. // File selector state
  30. const [currentFilePath, setCurrentFilePath] = useState(watchPath);
  31. const [isDropdownOpen, setIsDropdownOpen] = useState(false);
  32. const [isDropdownFocused, setIsDropdownFocused] = useState(false);
  33. const [selectedFileIndex, setSelectedFileIndex] = useState(0);
  34. // Terminal dimensions
  35. const rows = stdout?.rows || 24;
  36. const cols = stdout?.columns || 80;
  37. const contentHeight = rows - 4; // Header (2) + Footer (2)
  38. // Get files in directory
  39. const files = useDirectoryFiles(watchDir);
  40. // File watcher - watch the current file
  41. const { content: watchedContent, error } = useFileWatcher(currentFilePath);
  42. // Enable mouse tracking
  43. useLayoutEffect(() => {
  44. if (mouseEnabled) {
  45. process.stdout.write(ENABLE_MOUSE);
  46. // Listen for mouse events on stdin
  47. const handleData = (data: Buffer) => {
  48. const str = data.toString();
  49. // SGR mouse format: \x1B[<button;x;yM or \x1B[<button;x;ym
  50. // Button 64 = scroll up, Button 65 = scroll down
  51. const sgrMatch = str.match(/\x1B\[<(\d+);(\d+);(\d+)([Mm])/);
  52. if (sgrMatch) {
  53. const button = parseInt(sgrMatch[1], 10);
  54. if (button === 64) {
  55. // Scroll up
  56. setScrollOffset(prev => Math.max(0, prev - 3));
  57. } else if (button === 65) {
  58. // Scroll down
  59. setScrollOffset(prev => Math.min(Math.max(0, totalLines - contentHeight), prev + 3));
  60. }
  61. }
  62. };
  63. process.stdin.on('data', handleData);
  64. return () => {
  65. process.stdout.write(DISABLE_MOUSE);
  66. process.stdin.off('data', handleData);
  67. };
  68. }
  69. }, [mouseEnabled, totalLines, contentHeight]);
  70. // Update content when file changes
  71. useEffect(() => {
  72. if (watchedContent !== null) {
  73. setContent(watchedContent);
  74. setSyncStatus('synced');
  75. setLastUpdate(new Date());
  76. setScrollOffset(0);
  77. const metaPath = watchDir + '/meta.json';
  78. readMeta(metaPath).then(setMeta).catch(() => {});
  79. }
  80. }, [watchedContent, watchDir]);
  81. // Keyboard input
  82. useInput((input, key) => {
  83. // Quit
  84. if (input === 'q' || (key.ctrl && input === 'c')) {
  85. if (mouseEnabled) {
  86. process.stdout.write(DISABLE_MOUSE);
  87. }
  88. exit();
  89. }
  90. // Other files (excluding current) for dropdown navigation
  91. const otherFiles = files.filter(f => f.path !== currentFilePath);
  92. // Tab - toggle file selector focus
  93. if (key.tab) {
  94. if (isDropdownFocused) {
  95. // Close dropdown and unfocus
  96. setIsDropdownFocused(false);
  97. setIsDropdownOpen(false);
  98. } else {
  99. // Focus and open dropdown
  100. setIsDropdownFocused(true);
  101. setIsDropdownOpen(true);
  102. // Start at first file
  103. setSelectedFileIndex(0);
  104. }
  105. return;
  106. }
  107. // Escape - close dropdown
  108. if (key.escape && isDropdownOpen) {
  109. setIsDropdownOpen(false);
  110. setIsDropdownFocused(false);
  111. return;
  112. }
  113. // When dropdown is open, arrow keys navigate files
  114. if (isDropdownOpen) {
  115. if (key.upArrow) {
  116. setSelectedFileIndex(prev => Math.max(0, prev - 1));
  117. return;
  118. }
  119. if (key.downArrow) {
  120. setSelectedFileIndex(prev => Math.min(otherFiles.length - 1, prev + 1));
  121. return;
  122. }
  123. if (key.return && otherFiles[selectedFileIndex]) {
  124. // Select file and close dropdown
  125. setCurrentFilePath(otherFiles[selectedFileIndex].path);
  126. setIsDropdownOpen(false);
  127. setIsDropdownFocused(false);
  128. setScrollOffset(0);
  129. return;
  130. }
  131. }
  132. // Scrolling (only when dropdown is closed)
  133. if (key.upArrow) {
  134. setScrollOffset(prev => Math.max(0, prev - 1));
  135. }
  136. if (key.downArrow) {
  137. setScrollOffset(prev => Math.min(Math.max(0, totalLines - contentHeight), prev + 1));
  138. }
  139. if (key.pageUp) {
  140. setScrollOffset(prev => Math.max(0, prev - contentHeight));
  141. }
  142. if (key.pageDown) {
  143. setScrollOffset(prev => Math.min(Math.max(0, totalLines - contentHeight), prev + contentHeight));
  144. }
  145. // Home/End and vim-style navigation
  146. if (input === 'g' || key.meta && key.upArrow) {
  147. setScrollOffset(0);
  148. }
  149. if (input === 'G' || key.meta && key.downArrow) {
  150. setScrollOffset(Math.max(0, totalLines - contentHeight));
  151. }
  152. // Refresh
  153. if (input === 'r') {
  154. setSyncStatus('watching');
  155. }
  156. // Toggle mouse capture
  157. if (input === 'm') {
  158. setMouseEnabled(prev => {
  159. const newValue = !prev;
  160. if (newValue) {
  161. process.stdout.write(ENABLE_MOUSE);
  162. } else {
  163. process.stdout.write(DISABLE_MOUSE);
  164. }
  165. return newValue;
  166. });
  167. }
  168. // Edit in external editor
  169. if (input === 'e' && !isEditing && content) {
  170. setIsEditing(true);
  171. // Disable mouse while editing
  172. if (mouseEnabled) {
  173. process.stdout.write(DISABLE_MOUSE);
  174. }
  175. editInExternalEditor(currentFilePath)
  176. .then(() => {
  177. setIsEditing(false);
  178. // Re-enable mouse if it was on
  179. if (mouseEnabled) {
  180. process.stdout.write(ENABLE_MOUSE);
  181. }
  182. })
  183. .catch(() => {
  184. setIsEditing(false);
  185. if (mouseEnabled) {
  186. process.stdout.write(ENABLE_MOUSE);
  187. }
  188. });
  189. }
  190. });
  191. // Status info
  192. const currentFileName = currentFilePath.split(/[/\\]/).pop() || 'file';
  193. const timestampStr = lastUpdate
  194. ? `${lastUpdate.toLocaleDateString()} ${lastUpdate.toLocaleTimeString()}`
  195. : null;
  196. const scrollHint = totalLines > contentHeight
  197. ? ` ${scrollOffset + 1}-${Math.min(scrollOffset + contentHeight, totalLines)}/${totalLines}`
  198. : '';
  199. const mouseHint = mouseEnabled ? 'on' : 'off';
  200. const hints = isEditing
  201. ? 'Editing...'
  202. : isDropdownOpen
  203. ? '[Tab] Close [↑↓] Nav [Enter] Open'
  204. : `[Tab] Files [e] Edit [m] ${mouseHint} [q] Quit${scrollHint}`;
  205. return (
  206. <Box flexDirection="column" height={rows}>
  207. <Header
  208. title="✿ CANVAS"
  209. width={cols}
  210. files={files}
  211. currentFile={currentFilePath}
  212. selectedFileIndex={selectedFileIndex}
  213. isDropdownOpen={isDropdownOpen}
  214. isDropdownFocused={isDropdownFocused}
  215. />
  216. <Box flexGrow={1} flexDirection="column" overflow="hidden">
  217. {content ? (
  218. <MarkdownView
  219. content={content}
  220. scrollOffset={scrollOffset}
  221. maxHeight={contentHeight}
  222. onLineCount={setTotalLines}
  223. />
  224. ) : (
  225. <Box padding={1}>
  226. <Text color="gray">
  227. {error ? (
  228. <Text color="red">{error}</Text>
  229. ) : (
  230. `Watching ${watchPath}...\n\nUse /canvas write in Claude Code to send content.`
  231. )}
  232. </Text>
  233. </Box>
  234. )}
  235. </Box>
  236. <StatusBar
  237. status={syncStatus}
  238. filename={currentFileName}
  239. timestamp={timestampStr}
  240. hints={hints}
  241. width={cols}
  242. />
  243. </Box>
  244. );
  245. };