instead of to allow line wrapping.
: outputContent);
}
}
/*
* 'Shell commands' plugin for Obsidian.
* Copyright (C) 2021 - 2023 Jarkko Linnanvirta
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.0 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*
* Contact the author (Jarkko Linnanvirta): https://github.com/Taitava/
*/
class OutputChannel_CurrentFile extends OutputChannel {
async _handleBuffered(outputContent) {
this.handle(outputContent);
}
async _handleRealtime(outputContent) {
this.handle(outputContent);
}
handle(output_message) {
const editor = getEditor(this.app);
const view = getView(this.app);
if (null === editor) {
// For some reason it's not possible to get an editor.
this.plugin.newError("Could not get an editor instance! Please create a discussion in GitHub. The command output is in the next error box:");
this.plugin.newError(output_message); // Good to output it at least some way.
debugLog("OutputChannel_CurrentFile: Could not get an editor instance.");
return;
}
// Check if the view is in source mode
if (null === view) {
// For some reason it's not possible to get an editor, but it's not a big problem.
debugLog("OutputChannel_CurrentFile: Could not get a view instance.");
}
else {
// We do have a view
if ("source" !== view.getMode()) {
// Warn that the output might go to an unexpected place in the note file.
this.plugin.newNotification("Note that your active note is not in 'Edit' mode! The output comes visible when you switch to 'Edit' mode again!");
}
}
// Insert into the current file
this.insertIntoEditor(editor, output_message);
}
}
/**
* There can be both "stdout" and "stderr" present at the same time, or just one of them. If both are present, they
* will be joined together with " " as a separator.
* @protected
*/
OutputChannel_CurrentFile.combine_output_streams = " ";
/*
* 'Shell commands' plugin for Obsidian.
* Copyright (C) 2021 - 2023 Jarkko Linnanvirta
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.0 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*
* Contact the author (Jarkko Linnanvirta): https://github.com/Taitava/
*/
class OutputChannel_CurrentFileCaret extends OutputChannel_CurrentFile {
/**
* Inserts text into the given editor, at caret position.
*
* @param editor
* @param output_message
* @protected
*/
insertIntoEditor(editor, output_message) {
editor.replaceSelection(output_message);
}
}
OutputChannel_CurrentFileCaret.title = "Current file: caret position";
OutputChannel_CurrentFileCaret.hotkey_letter = "R";
/*
* 'Shell commands' plugin for Obsidian.
* Copyright (C) 2021 - 2023 Jarkko Linnanvirta
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.0 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*
* Contact the author (Jarkko Linnanvirta): https://github.com/Taitava/
*/
class OutputChannel_CurrentFileTop extends OutputChannel_CurrentFile {
/**
* Inserts text into the given editor, at top.
*
* @param editor
* @param output_message
* @protected
*/
insertIntoEditor(editor, output_message) {
const top_position = editor.offsetToPos(0);
editor.replaceRange(output_message, top_position);
}
}
OutputChannel_CurrentFileTop.title = "Current file: top";
OutputChannel_CurrentFileTop.hotkey_letter = "T";
/*
* 'Shell commands' plugin for Obsidian.
* Copyright (C) 2021 - 2023 Jarkko Linnanvirta
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.0 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*
* Contact the author (Jarkko Linnanvirta): https://github.com/Taitava/
*/
class OutputChannel_StatusBar extends OutputChannel {
constructor() {
super(...arguments);
/**
* All received output cumulatively. Subsequent handlings will then use the whole output, not just new parts.
* Only used in "realtime" mode.
*
* @private
*/
this.realtimeContentBuffer = "";
}
async _handleBuffered(outputContent) {
this.setStatusBarContent(outputContent);
}
async _handleRealtime(outputContent) {
this.realtimeContentBuffer += outputContent;
this.setStatusBarContent(this.realtimeContentBuffer);
}
setStatusBarContent(outputContent) {
const status_bar_element = this.plugin.getOutputStatusBarElement();
outputContent = outputContent.trim();
// Full output (shown when hovering with mouse)
status_bar_element.setAttr("aria-label", outputContent);
// FIXME: Make the statusbar element support HTML content better. Need to:
// - Make the hover content appear in a real HTML element, not in aria-label="" attribute.
// - Ensure the always visible bottom line contains all formatting that might come from lines above it.
// Show last line permanently.
const output_message_lines = outputContent.split(/(\r\n|\r|\n|
)/u); //
is here just in case, haven't tested if ansi_up adds it or not.
const last_output_line = output_message_lines[output_message_lines.length - 1];
status_bar_element.setText(obsidian.sanitizeHTMLToDom(last_output_line));
}
}
OutputChannel_StatusBar.title = "Status bar";
OutputChannel_StatusBar.accepts_empty_output = true;
OutputChannel_StatusBar.hotkey_letter = "S";
/**
* Combine stdout and stderr (in case both of them happen to be present).
* @protected
*/
OutputChannel_StatusBar.combine_output_streams = os.EOL + os.EOL;
/*
* 'Shell commands' plugin for Obsidian.
* Copyright (C) 2021 - 2023 Jarkko Linnanvirta
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.0 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*
* Contact the author (Jarkko Linnanvirta): https://github.com/Taitava/
*/
class OutputChannel_CurrentFileBottom extends OutputChannel_CurrentFile {
/**
* Inserts text into the given editor, at bottom.
*
* @param editor
* @param output_message
* @protected
*/
insertIntoEditor(editor, output_message) {
const bottom_position = {
ch: editor.getLine(editor.lastLine()).length,
line: editor.lastLine(), // ... the last line.
}; // *) But do not subtract 1, because ch is zero-based, so when .length is used without -1, we are pointing AFTER the last character.
editor.replaceRange(output_message, bottom_position);
}
}
OutputChannel_CurrentFileBottom.title = "Current file: bottom";
OutputChannel_CurrentFileBottom.hotkey_letter = "B";
/*
* 'Shell commands' plugin for Obsidian.
* Copyright (C) 2021 - 2023 Jarkko Linnanvirta
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.0 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*
* Contact the author (Jarkko Linnanvirta): https://github.com/Taitava/
*/
class OutputChannel_Clipboard extends OutputChannel {
constructor() {
super(...arguments);
/**
* All received output cumulatively. Subsequent handlings will then use the whole output, not just new parts.
* Only used in "realtime" mode.
*
* @private
*/
this.realtimeContentBuffer = "";
}
async _handleBuffered(outputContent) {
await copyToClipboard(outputContent);
this.notify(outputContent);
}
async _handleRealtime(outputContent) {
this.realtimeContentBuffer += outputContent;
await copyToClipboard(this.realtimeContentBuffer);
this.notify(this.realtimeContentBuffer);
}
notify(output_message) {
if (this.plugin.settings.output_channel_clipboard_also_outputs_to_notification) {
// Notify the user so they know a) what was copied to clipboard, and b) that their command has finished execution.
this.plugin.newNotification("Copied to clipboard: " + os.EOL + output_message + os.EOL + os.EOL + "(Notification can be turned off in settings.)");
}
}
}
OutputChannel_Clipboard.title = "Clipboard";
OutputChannel_Clipboard.hotkey_letter = "L";
/**
* There can be both "stdout" and "stderr" present at the same time, or just one of them. If both are present, they
* will be joined together with " " as a separator.
* @protected
*/
OutputChannel_Clipboard.combine_output_streams = " "; // TODO: Change to "" as there should be no extra space between stdout and stderr. Compare it to the terminal: AFAIK there is no separation between stdout and stderr outputs, just that typically each output ends with a newline.
/*
* 'Shell commands' plugin for Obsidian.
* Copyright (C) 2021 - 2023 Jarkko Linnanvirta
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.0 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*
* Contact the author (Jarkko Linnanvirta): https://github.com/Taitava/
*/
/**
* TODO: Move this to TShellCommand.
*/
function getHotkeysForShellCommand(plugin, shell_command_id) {
// Retrieve all hotkeys set by user.
// @ts-ignore PRIVATE API
const app_custom_hotkeys = plugin.app.hotkeyManager?.customKeys;
if (!app_custom_hotkeys) {
debugLog("getHotkeysForShellCommand() failed, will return an empty array.");
return [];
}
// Get only our hotkeys.
const hotkey_index = plugin.getPluginId() + ":" + plugin.generateObsidianCommandId(shell_command_id); // E.g. "obsidian-shellcommands:shell-command-0"
debugLog("getHotkeysForShellCommand() succeeded.");
return app_custom_hotkeys[hotkey_index] ?? []; // If no hotkey array is set for this command, return an empty array. Although I do believe that all commands do have an array anyway, but have this check just in case.
}
/**
* TODO: Is there a way to make Obsidian do this conversion for us? Check this: https://github.com/pjeby/hotkey-helper/blob/c8a032e4c52bd9ce08cb909cec15d1ed9d0a3439/src/plugin.js#L4-L6
*
* @param hotkey
* @constructor
*/
function HotkeyToString(hotkey) {
const keys = [];
hotkey.modifiers.forEach((modifier) => {
let modifier_key = modifier.toString(); // This is one of 'Mod' | 'Ctrl' | 'Meta' | 'Shift' | 'Alt'
if ("Mod" === modifier_key) {
// Change "Mod" to something more meaningful.
modifier_key = CmdOrCtrl(); // isMacOS should also be true if the device is iPhone/iPad. Can be handy if this plugin gets mobile support some day.
}
keys.push(modifier_key);
});
keys.push(hotkey.key); // This is something like a letter ('A', 'B' etc) or space/enter/whatever.
return keys.join(" + ");
}
function CmdOrCtrl() {
return obsidian.Platform.isMacOS ? "Cmd" : "Ctrl";
}
function isCmdOrCtrlPressed(event) {
if (obsidian.Platform.isMacOS) {
return event.metaKey;
}
else {
return event.ctrlKey;
}
}
/*
* 'Shell commands' plugin for Obsidian.
* Copyright (C) 2021 - 2023 Jarkko Linnanvirta
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, version 3.0 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*
* Contact the author (Jarkko Linnanvirta): https://github.com/Taitava/
*/
class OutputChannel_Modal extends OutputChannel {
initialize() {
// Initialize a modal (but don't open yet)
this.modal = new OutputModal(this.plugin, this.t_shell_command, this.shell_command_parsing_result, this.processTerminator);
}
async _handleBuffered(outputs, error_code) {
// Pass outputs to modal
this.modal.setOutputContents(outputs);
// Define a possible error code to be shown on the modal.
if (error_code !== null) {
this.modal.setExitCode(error_code);
}
// Done
this.modal.open();
}
async _handleRealtime(outputContent, outputStreamName) {
this.modal.addOutputContent(outputStreamName, outputContent);
if (!this.modal.isOpen()) {
this.modal.open();
}
}
/**
* @param exitCode Can be null if user terminated the process by clicking a button. In other places exitCode can be null if process is still running, but here that cannot be the case.
*
* @protected
*/
_endRealtime(exitCode) {
// Delete terminator button as the process is already ended.
this.modal.removeProcessTerminatorButton();
// Pass exitCode to the modal
this.modal.setExitCode(exitCode);
}
}
OutputChannel_Modal.title = "Ask after execution";
class OutputModal extends SC_Modal {
constructor(plugin, t_shell_command, shell_command_parsing_result, processTerminator) {
super(plugin);
this.processTerminator = processTerminator;
this.exit_code = null; // TODO: Think about changing the logic: exit code could be undefined when it's not received, and null when a user has terminated the execution. The change needs to be done in the whole plugin, although I only wrote about it in this OutputModal class.
this.t_shell_command = t_shell_command;
this.shell_command_parsing_result = shell_command_parsing_result;
this.createOutputFields();
}
/**
* Called when doing "buffered" output handling.
*
* @param outputs
*/
setOutputContents(outputs) {
Object.getOwnPropertyNames(outputs).forEach((outputStreamName) => {
const outputField = this.outputFields[outputStreamName];
// Set field value
const textareaComponent = outputField.components.first();
const outputContent = outputs[outputStreamName];
textareaComponent.setValue(outputContent); // as string = outputContent is not undefined because of the .forEach() loop.
// Make field visible (if it's not already)
outputField.settingEl.matchParent(".SC-hide")?.removeClass("SC-hide");
});
}
/**
* Called when doing "realtime" output handling.
*
* @param outputStreamName
* @param outputContent
*/
addOutputContent(outputStreamName, outputContent) {
const outputField = this.outputFields[outputStreamName];
// Update field value
const textareaComponent = outputField.components.first();
textareaComponent.setValue(textareaComponent.getValue() + outputContent);
// Make field visible (if it's not already)
outputField.settingEl.matchParent(".SC-hide")?.removeClass("SC-hide");
}
onOpen() {
super.onOpen();
this.modalEl.addClass("SC-modal-output");
// Heading
const heading = this.shell_command_parsing_result.alias;
this.titleEl.innerText = heading ? heading : "Shell command output"; // TODO: Use this.setTitle() instead.
// Shell command preview
this.modalEl.createEl("pre", {
text: this.shell_command_parsing_result.unwrappedShellCommandContent,
attr: { class: "SC-no-margin SC-wrappable" }
} // No margin so that exit code will be near.
);
// Container for terminating button and exit code
const processResultContainer = this.modalEl.createDiv();
// 'Request to terminate the process' icon button
if (this.processTerminator) {
this.processTerminatorButtonContainer = processResultContainer.createEl('span');
this.plugin.createRequestTerminatingButton(this.processTerminatorButtonContainer, this.processTerminator);
}
// Exit code (put on same line with process terminator button, if exists)
this.exitCodeElement = processResultContainer.createEl("small", { text: "Executing...", attr: { style: "font-weight: bold;" } }); // Show "Executing..." before an actual exit code is received.
if (this.exit_code !== null) {
this.displayExitCode();
}
// Output fields
this.modalEl.insertAdjacentElement("beforeend", this.outputFieldsContainer);
// Focus on the first output field
this.focusFirstField();
// A tip about selecting text.
this.modalEl.createDiv({
text: "Tip! If you select something, only the selected text will be used.",
attr: { class: "setting-item-description" /* A CSS class defined by Obsidian. */ },
});
}
createOutputFields() {
// Create a parent-less container. onOpen() will place it in the correct place.
this.outputFieldsContainer = document.createElement('div');
// Create field containers in correct order
let stdoutFieldContainer;
let stderrFieldContainer;
switch (this.t_shell_command.getOutputChannelOrder()) {
case "stdout-first": {
stdoutFieldContainer = this.outputFieldsContainer.createDiv();
stderrFieldContainer = this.outputFieldsContainer.createDiv();
break;
}
case "stderr-first": {
stderrFieldContainer = this.outputFieldsContainer.createDiv();
stdoutFieldContainer = this.outputFieldsContainer.createDiv();
break;
}
}
// Create fields
this.outputFields = {
stdout: this.createOutputField("stdout", stdoutFieldContainer),
stderr: this.createOutputField("stderr", stderrFieldContainer),
};
// Define hotkeys.
const outputChannelClasses = getOutputChannelClasses();
for (const outputChannelName of Object.getOwnPropertyNames(outputChannelClasses)) {
const outputChannelClass = outputChannelClasses[outputChannelName];
// Ensure this channel is not excluded by checking that is has a hotkey defined.
if (outputChannelClass.hotkey_letter) {
const hotkeyHandler = () => {
const activeTextarea = this.getActiveTextarea();
if (activeTextarea) {
this.redirectOutput(outputChannelName, activeTextarea.outputStreamName, activeTextarea.textarea);
}
};
// 1. hotkey: Ctrl/Cmd + number: handle output.
this.scope.register(["Ctrl"], outputChannelClass.hotkey_letter, hotkeyHandler);
// 2. hotkey: Ctrl/Cmd + Shift + number: handle output and close the modal.
this.scope.register(["Ctrl", "Shift"], outputChannelClass.hotkey_letter, () => {
hotkeyHandler();
this.close();
});
}
}
// Hide the fields' containers at the beginning. They will be shown when content is added.
stdoutFieldContainer.addClass("SC-hide");
stderrFieldContainer.addClass("SC-hide");
}
createOutputField(output_stream, containerElement) {
containerElement.createEl("hr", { attr: { class: "SC-no-margin" } });
// Output stream name
new obsidian.Setting(containerElement)
.setName(output_stream)
.setHeading()
.setClass("SC-no-bottom-border");
// Textarea
const textarea_setting = new obsidian.Setting(containerElement)
.addTextArea(() => { }) // No need to do anything, but the method requires a callback.
;
textarea_setting.infoEl.addClass("SC-hide"); // Make room for the textarea by hiding the left column.
textarea_setting.settingEl.addClass("SC-output-channel-modal-textarea-container", "SC-no-top-border");
// Add controls for redirecting the output to another channel.
const redirect_setting = new obsidian.Setting(containerElement)
.setDesc("Redirect:")
.setClass("SC-no-top-border")
.setClass("SC-output-channel-modal-redirection-buttons-container") // I think this calls actually HTMLDivElement.addClass(), so it should not override the previous .setClass().
;
const outputChannels = getOutputChannelClasses();
Object.getOwnPropertyNames(outputChannels).forEach((output_channel_name) => {
const outputChannelClass = outputChannels[output_channel_name];
// Ensure this channel is not excluded by checking that is has a hotkey defined.
if (outputChannelClass.hotkey_letter) {
// Ensure the output channel accepts this output stream. E.g. OutputChannel_OpenFiles does not accept "stderr".
if (outputChannelClass.acceptsOutputStream(output_stream)) {
const textarea_element = textarea_setting.settingEl.find("textarea");
// Create the button
redirect_setting.addButton((button) => {
button.onClick(async (event) => {
// Handle output
await this.redirectOutput(output_channel_name, output_stream, textarea_element);
// Finish
if (isCmdOrCtrlPressed(event)) {
// Special click, control/command key is pressed.
// Close the modal.
this.close();
}
else {
// Normal click, control key is not pressed.
// Do not close the modal.
textarea_element.focus(); // Bring the focus back to the textarea in order to show a possible highlight (=selection) again.
}
});
// Define button texts and assign hotkeys
const output_channel_title = outputChannelClass.getTitle(output_stream);
// Button text
button.setButtonText(output_channel_title);
// Tips about hotkeys
button.setTooltip(`Redirect: Normal click OR ${CmdOrCtrl()} + ${outputChannelClass.hotkey_letter}.`
+ os.EOL + os.EOL +
`Redirect and close the modal: ${CmdOrCtrl()} + click OR ${CmdOrCtrl()} + Shift + ${outputChannelClass.hotkey_letter}.`);
});
}
}
});
return textarea_setting;
}
async redirectOutput(outputChannelName, outputStreamName, sourceTextarea) {
const outputContent = getSelectionFromTextarea(sourceTextarea, true) // Use the selection, or...
?? sourceTextarea.value // ...use the whole text, if nothing is selected.
;
const output_streams = {
[outputStreamName]: outputContent,
};
const outputChannel = initializeOutputChannel(outputChannelName, this.plugin, this.t_shell_command, this.shell_command_parsing_result, "buffered", // Use "buffered" mode even if this modal was opened in "realtime" mode, because at this point the output redirection is a single-time job, not recurring.
this.processTerminator);
await outputChannel.handleBuffered(output_streams, this.exit_code, false); // false: Disable output wrapping as it's already wrapped before the output content was passed to this modal.
}
/**
* Looks up for a