Browse Source

feat(canvas): Add file selector dropdown and beta flag

- Add Tab-activated file dropdown to switch between docs
- Scan watch directory for .md/.txt files
- Minimal styling: filename with arrow, simple list
- Mark canvas as beta in header and README
- Fix path resolution for Windows

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
0xDarkMatter 3 months ago
parent
commit
387b847cbd

+ 2 - 0
canvas-tui/README.md

@@ -1,5 +1,7 @@
 # @claude-mods/canvas-tui
 
+> **Beta** - Under active development. Features may change.
+
 Terminal canvas for Claude Code - live markdown preview in split panes.
 
 Works with any terminal that supports split panes: **Warp**, **tmux**, **iTerm2**, **Windows Terminal**, and more.

+ 71 - 6
canvas-tui/src/app.tsx

@@ -4,6 +4,7 @@ import { Header } from './components/Header.js';
 import { MarkdownView } from './components/MarkdownView.js';
 import { StatusBar } from './components/StatusBar.js';
 import { useFileWatcher } from './hooks/useFileWatcher.js';
+import { useDirectoryFiles } from './hooks/useDirectoryFiles.js';
 import { readMeta, type CanvasMeta } from './lib/ipc.js';
 import { editInExternalEditor } from './lib/editor.js';
 
@@ -29,13 +30,22 @@ export const App: React.FC<AppProps> = ({ watchPath, watchDir, enableMouse = tru
   const [mouseEnabled, setMouseEnabled] = useState(enableMouse);
   const [isEditing, setIsEditing] = useState(false);
 
+  // File selector state
+  const [currentFilePath, setCurrentFilePath] = useState(watchPath);
+  const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+  const [isDropdownFocused, setIsDropdownFocused] = useState(false);
+  const [selectedFileIndex, setSelectedFileIndex] = useState(0);
+
   // Terminal dimensions
   const rows = stdout?.rows || 24;
   const cols = stdout?.columns || 80;
   const contentHeight = rows - 4; // Header (2) + Footer (2)
 
-  // File watcher
-  const { content: watchedContent, error } = useFileWatcher(watchPath);
+  // Get files in directory
+  const files = useDirectoryFiles(watchDir);
+
+  // File watcher - watch the current file
+  const { content: watchedContent, error } = useFileWatcher(currentFilePath);
 
   // Enable mouse tracking
   useLayoutEffect(() => {
@@ -85,6 +95,7 @@ export const App: React.FC<AppProps> = ({ watchPath, watchDir, enableMouse = tru
 
   // Keyboard input
   useInput((input, key) => {
+    // Quit
     if (input === 'q' || (key.ctrl && input === 'c')) {
       if (mouseEnabled) {
         process.stdout.write(DISABLE_MOUSE);
@@ -92,7 +103,51 @@ export const App: React.FC<AppProps> = ({ watchPath, watchDir, enableMouse = tru
       exit();
     }
 
-    // Scrolling
+    // Tab - toggle file selector focus
+    if (key.tab) {
+      if (isDropdownFocused) {
+        // Close dropdown and unfocus
+        setIsDropdownFocused(false);
+        setIsDropdownOpen(false);
+      } else {
+        // Focus and open dropdown
+        setIsDropdownFocused(true);
+        setIsDropdownOpen(true);
+        // Set selected index to current file
+        const currentIndex = files.findIndex(f => f.path === currentFilePath);
+        setSelectedFileIndex(currentIndex >= 0 ? currentIndex : 0);
+      }
+      return;
+    }
+
+    // Escape - close dropdown
+    if (key.escape && isDropdownOpen) {
+      setIsDropdownOpen(false);
+      setIsDropdownFocused(false);
+      return;
+    }
+
+    // When dropdown is open, arrow keys navigate files
+    if (isDropdownOpen) {
+      if (key.upArrow) {
+        setSelectedFileIndex(prev => Math.max(0, prev - 1));
+        return;
+      }
+      if (key.downArrow) {
+        setSelectedFileIndex(prev => Math.min(files.length - 1, prev + 1));
+        return;
+      }
+      if (key.return && files[selectedFileIndex]) {
+        // Select file and close dropdown
+        setCurrentFilePath(files[selectedFileIndex].path);
+        setIsDropdownOpen(false);
+        setIsDropdownFocused(false);
+        setScrollOffset(0);
+        return;
+      }
+    }
+
+    // Scrolling (only when dropdown is closed)
     if (key.upArrow) {
       setScrollOffset(prev => Math.max(0, prev - 1));
     }
@@ -139,7 +194,7 @@ export const App: React.FC<AppProps> = ({ watchPath, watchDir, enableMouse = tru
       if (mouseEnabled) {
         process.stdout.write(DISABLE_MOUSE);
       }
-      editInExternalEditor(watchPath)
+      editInExternalEditor(currentFilePath)
         .then(() => {
           setIsEditing(false);
           // Re-enable mouse if it was on
@@ -170,11 +225,21 @@ export const App: React.FC<AppProps> = ({ watchPath, watchDir, enableMouse = tru
   const mouseHint = mouseEnabled ? 'mouse:on' : 'mouse:off';
   const hints = isEditing
     ? 'Editing... save & quit editor to return'
-    : `q:quit | e:edit | m:${mouseHint} | arrows${scrollHint}`;
+    : isDropdownOpen
+    ? 'Tab:close | ↑↓:select | Enter:open'
+    : `Tab:files | e:edit | m:${mouseHint} | q:quit${scrollHint}`;
 
   return (
     <Box flexDirection="column" height={rows}>
-      <Header title="Canvas" contentType={meta?.contentType || 'doc'} width={cols} />
+      <Header
+        title="Canvas (beta)"
+        width={cols}
+        files={files}
+        currentFile={currentFilePath}
+        selectedFileIndex={selectedFileIndex}
+        isDropdownOpen={isDropdownOpen}
+        isDropdownFocused={isDropdownFocused}
+      />
 
       <Box flexGrow={1} flexDirection="column" overflow="hidden">
         {content ? (

+ 61 - 0
canvas-tui/src/components/FileSelector.tsx

@@ -0,0 +1,61 @@
+import React from 'react';
+import { Box, Text } from 'ink';
+import { FileInfo, truncateFilename } from '../hooks/useDirectoryFiles.js';
+
+interface FileSelectorProps {
+  files: FileInfo[];
+  currentFile: string;
+  selectedIndex: number;
+  isOpen: boolean;
+  isFocused: boolean;
+}
+
+const MAX_DISPLAY_LENGTH = 20;
+
+export const FileSelector: React.FC<FileSelectorProps> = ({
+  files,
+  currentFile,
+  selectedIndex,
+  isOpen,
+  isFocused,
+}) => {
+  // Get current filename from path
+  const currentFileName = currentFile.split(/[/\\]/).pop() || 'No file';
+  const displayName = truncateFilename(currentFileName, MAX_DISPLAY_LENGTH);
+
+  return (
+    <Box flexDirection="column" alignItems="flex-end">
+      {/* Selector - just filename with arrow */}
+      <Text
+        inverse={isFocused}
+        color={isFocused ? undefined : 'gray'}
+      >
+        {displayName} {isOpen ? '▲' : '▼'}
+      </Text>
+
+      {/* Dropdown - simple list */}
+      {isOpen && files.length > 0 && (
+        <Box flexDirection="column" marginTop={0}>
+          {files.slice(0, 6).map((file, index) => {
+            const isSelected = index === selectedIndex;
+            const isCurrent = file.name === currentFileName;
+            const name = truncateFilename(file.name, MAX_DISPLAY_LENGTH);
+
+            return (
+              <Text
+                key={file.path}
+                inverse={isSelected}
+                color={isCurrent && !isSelected ? 'cyan' : undefined}
+              >
+                {isSelected ? '› ' : '  '}{name}
+              </Text>
+            );
+          })}
+          {files.length > 6 && (
+            <Text dimColor>  +{files.length - 6} more</Text>
+          )}
+        </Box>
+      )}
+    </Box>
+  );
+};

+ 33 - 12
canvas-tui/src/components/Header.tsx

@@ -1,27 +1,48 @@
 import React from 'react';
 import { Box, Text } from 'ink';
+import { FileSelector } from './FileSelector.js';
+import { FileInfo } from '../hooks/useDirectoryFiles.js';
 
 interface HeaderProps {
   title: string;
-  contentType: string;
   width: number;
+  // File selector props
+  files: FileInfo[];
+  currentFile: string;
+  selectedFileIndex: number;
+  isDropdownOpen: boolean;
+  isDropdownFocused: boolean;
 }
 
-export const Header: React.FC<HeaderProps> = ({ title, contentType, width }) => {
-  const typeLabel = contentType.charAt(0).toUpperCase() + contentType.slice(1);
+export const Header: React.FC<HeaderProps> = ({
+  title,
+  width,
+  files,
+  currentFile,
+  selectedFileIndex,
+  isDropdownOpen,
+  isDropdownFocused,
+}) => {
   const leftContent = ` ${title} `;
-  const rightContent = ` ${typeLabel} `;
 
-  // Calculate padding for centering
-  const totalContentLength = leftContent.length + rightContent.length;
-  const padding = Math.max(0, width - totalContentLength - 2);
+  // Reserve space for file selector (~30 chars)
+  const selectorWidth = 30;
+  const padding = Math.max(0, width - leftContent.length - selectorWidth - 2);
 
   return (
-    <Box borderStyle="single" borderBottom={true} borderTop={false} borderLeft={false} borderRight={false}>
-      <Box width={width}>
-        <Text bold color="blue">{leftContent}</Text>
-        <Text>{' '.repeat(padding)}</Text>
-        <Text color="gray">{rightContent}</Text>
+    <Box flexDirection="column">
+      <Box borderStyle="single" borderBottom={true} borderTop={false} borderLeft={false} borderRight={false}>
+        <Box width={width} justifyContent="space-between">
+          <Text bold color="blue">{leftContent}</Text>
+          <Text>{' '.repeat(padding)}</Text>
+          <FileSelector
+            files={files}
+            currentFile={currentFile}
+            selectedIndex={selectedFileIndex}
+            isOpen={isDropdownOpen}
+            isFocused={isDropdownFocused}
+          />
+        </Box>
       </Box>
     </Box>
   );

+ 97 - 0
canvas-tui/src/hooks/useDirectoryFiles.ts

@@ -0,0 +1,97 @@
+import { useState, useEffect } from 'react';
+import fs from 'fs';
+import path from 'path';
+
+export interface FileInfo {
+  name: string;
+  path: string;
+  mtime: Date;
+}
+
+/**
+ * Scans a directory for markdown and text files.
+ * Returns sorted list (most recently modified first).
+ */
+export function useDirectoryFiles(dirPath: string): FileInfo[] {
+  const [files, setFiles] = useState<FileInfo[]>([]);
+
+  useEffect(() => {
+    const scanDirectory = () => {
+      try {
+        // Resolve to absolute path
+        const absoluteDir = path.resolve(dirPath);
+        const entries = fs.readdirSync(absoluteDir, { withFileTypes: true });
+        const fileInfos: FileInfo[] = [];
+
+        for (const entry of entries) {
+          if (!entry.isFile()) continue;
+
+          const ext = path.extname(entry.name).toLowerCase();
+          if (ext !== '.md' && ext !== '.txt') continue;
+
+          const filePath = path.join(absoluteDir, entry.name);
+          const stats = fs.statSync(filePath);
+
+          fileInfos.push({
+            name: entry.name,
+            path: filePath,
+            mtime: stats.mtime,
+          });
+        }
+
+        // Sort by modified time (most recent first)
+        fileInfos.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
+        setFiles(fileInfos);
+      } catch (err) {
+        // Directory might not exist yet
+        setFiles([]);
+      }
+    };
+
+    // Initial scan
+    scanDirectory();
+
+    // Re-scan periodically (every 2 seconds) to catch new files
+    const interval = setInterval(scanDirectory, 2000);
+
+    return () => clearInterval(interval);
+  }, [dirPath]);
+
+  return files;
+}
+
+/**
+ * Format relative time for display (e.g., "now", "1h", "3d")
+ */
+export function formatRelativeTime(date: Date): string {
+  const now = Date.now();
+  const diff = now - date.getTime();
+
+  const seconds = Math.floor(diff / 1000);
+  const minutes = Math.floor(seconds / 60);
+  const hours = Math.floor(minutes / 60);
+  const days = Math.floor(hours / 24);
+
+  if (seconds < 60) return 'now';
+  if (minutes < 60) return `${minutes}m`;
+  if (hours < 24) return `${hours}h`;
+  if (days < 30) return `${days}d`;
+  return `${Math.floor(days / 30)}mo`;
+}
+
+/**
+ * Truncate filename to fit width, preserving extension
+ */
+export function truncateFilename(name: string, maxLength: number): string {
+  if (name.length <= maxLength) return name;
+
+  const ext = path.extname(name);
+  const base = path.basename(name, ext);
+  const availableLength = maxLength - ext.length - 3; // -3 for "..."
+
+  if (availableLength <= 0) {
+    return name.slice(0, maxLength - 3) + '...';
+  }
+
+  return base.slice(0, availableLength) + '...' + ext;
+}