Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions scripts/terminal_pty.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,46 @@
import termios
import select
import signal
import time

# Global to track child PID (also the process group ID) for signal handler
child_pid = None

def kill_process_group(pgid, sig):
"""Kill an entire process group."""
try:
os.killpg(pgid, sig)
except (ProcessLookupError, PermissionError, OSError):
pass

def cleanup_child(signum, frame):
"""Kill the entire process group when we receive a signal."""
global child_pid
if child_pid:
# Kill entire process group (child is group leader)
kill_process_group(child_pid, signal.SIGTERM)
# Give processes a moment to exit gracefully
for _ in range(10):
try:
pid, _ = os.waitpid(-child_pid, os.WNOHANG)
if pid != 0:
break
except ChildProcessError:
break
time.sleep(0.1)
else:
# Force kill the entire group if still running
kill_process_group(child_pid, signal.SIGKILL)
sys.exit(0)

def set_size(fd, cols, rows):
"""Set the PTY window size."""
winsize = struct.pack('HHHH', rows, cols, 0, 0)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)

def main():
global child_pid

# Parse args: terminal_pty.py [cols] [rows] [shell] [shell_args...]
if len(sys.argv) < 4:
print(f"Usage: {sys.argv[0]} cols rows shell [args...]", file=sys.stderr)
Expand All @@ -25,10 +58,17 @@ def main():
shell = sys.argv[3]
shell_args = sys.argv[3:] # Include shell as argv[0]

# Register signal handlers for cleanup
signal.signal(signal.SIGTERM, cleanup_child)
signal.signal(signal.SIGINT, cleanup_child)

pid, fd = pty.fork()
child_pid = pid # Store for signal handler

if pid == 0:
# Child process - exec the shell
# Child process - create new process group so we can kill entire tree
os.setpgrp()
# exec the shell
os.execvp(shell, shell_args)
sys.exit(1)

Expand Down Expand Up @@ -97,6 +137,19 @@ def main():
break
finally:
fcntl.fcntl(stdin_fd, fcntl.F_SETFL, old_flags)
# Ensure entire process group is terminated when we exit
if child_pid:
kill_process_group(child_pid, signal.SIGTERM)
for _ in range(10):
try:
wpid, _ = os.waitpid(-child_pid, os.WNOHANG)
if wpid != 0:
break
except ChildProcessError:
break
time.sleep(0.1)
else:
kill_process_group(child_pid, signal.SIGKILL)

if __name__ == '__main__':
main()
main()
18 changes: 18 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,24 @@ export default class VaultCodePlugin extends Plugin implements IVaultContext {
this.viewManager.focusTerminal();
}

/**
* Send a file path to Claude Code via MCP @-mention notification.
* Used by drag-and-drop handler for MCP integration.
* @param absolutePath - Absolute file path to send
* @returns true if MCP is enabled and notification was sent, false otherwise
*/
sendPathToClaudeCode(absolutePath: string): boolean {
if (!this.mcpIntegration) {
return false;
}

this.mcpIntegration.sendNotification(
createAtMentionedNotification(this.formatFilePath(absolutePath), null, null)
);

return true;
}

async loadSettings(): Promise<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial<VaultCodeSettings>);
}
Expand Down
103 changes: 103 additions & 0 deletions src/view/terminal-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export class TerminalView extends ItemView {
private escapeScope: Scope | null = null;
private fitTimeout: ReturnType<typeof setTimeout> | null = null;
private plugin: VaultCodePlugin;
private dragOverHandler: ((e: DragEvent) => void) | null = null;
private dropHandler: ((e: DragEvent) => void) | null = null;

constructor(leaf: WorkspaceLeaf, plugin: VaultCodePlugin) {
super(leaf);
Expand Down Expand Up @@ -138,6 +140,96 @@ export class TerminalView extends ItemView {

// Watch for Obsidian layout changes (sidebar resize, etc.)
this.registerEvent(this.app.workspace.on("layout-change", () => this.debouncedFit()));

// Handle file drag-and-drop
this.setupDragAndDrop();
}

private setupDragAndDrop(): void {
if (!this.termHost) return;

this.dragOverHandler = (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = "copy";
}
};

this.dropHandler = (e: DragEvent) => {
e.preventDefault();
if (!this.termProcess) return;

// Check for Obsidian internal file drag (obsidian:// URL)
const textData = e.dataTransfer?.getData("text/plain");
if (textData && textData.startsWith("obsidian://")) {
try {
const url = new URL(textData);
const filePath = url.searchParams.get("file");
if (filePath) {
const linkPath = decodeURIComponent(filePath);
// Obsidian URLs omit .md extension, resolve through metadataCache
const file = this.app.metadataCache.getFirstLinkpathDest(linkPath, "");
const relativePath = file?.path ?? linkPath;
// Use relative path for MCP (matches context menu behavior)
if (!this.plugin.sendPathToClaudeCode(relativePath)) {
this.writeAtMention(relativePath);
}
this.term?.focus();
return;
}
} catch {
// Failed to parse URL, try external file handling
}
}

// Handle external file drops
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const { webUtils } = require("electron");
for (let i = 0; i < files.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const filePath: string = webUtils.getPathForFile(files[i]);
if (filePath) {
// External files use absolute path
if (!this.plugin.sendPathToClaudeCode(filePath)) {
this.writeAtMention(filePath);
}
}
}
this.term?.focus();
return;
}

// Handle plain text drops (no files, not obsidian://)
// Check if it's an Obsidian folder path (folders don't use obsidian:// URLs)
if (textData && !textData.startsWith("obsidian://")) {
const vaultItem = this.app.vault.getAbstractFileByPath(textData);
if (vaultItem) {
// Use relative path for MCP (matches context menu behavior)
if (!this.plugin.sendPathToClaudeCode(vaultItem.path)) {
this.writeAtMention(vaultItem.path);
}
} else {
// Plain text, not a vault path
this.termProcess.write(textData);
}
this.term?.focus();
}
};

this.termHost.addEventListener("dragover", this.dragOverHandler);
this.termHost.addEventListener("drop", this.dropHandler);
}

/**
* Write @-mention syntax to terminal stdin.
* Used as fallback when MCP integration is not available.
*/
private writeAtMention(filePath: string): void {
// Quote path if it contains spaces (Claude Code @-mention syntax)
const formattedPath = filePath.includes(" ") ? `@'${filePath}' ` : `@${filePath} `;
this.termProcess?.write(formattedPath);
}

private updateTheme(): void {
Expand Down Expand Up @@ -250,6 +342,17 @@ export class TerminalView extends ItemView {
this.escapeScope = null;
}

if (this.termHost) {
if (this.dragOverHandler) {
this.termHost.removeEventListener("dragover", this.dragOverHandler);
this.dragOverHandler = null;
}
if (this.dropHandler) {
this.termHost.removeEventListener("drop", this.dropHandler);
this.dropHandler = null;
}
}

this.termProcess?.stop();
this.termProcess = null;

Expand Down