Skip to content

Commit

Permalink
Merge branch '#143-output-channel-open-file' into 0.11.0-beta.1-merge-#…
Browse files Browse the repository at this point in the history
…143-output-channel-open-file

# Conflicts:
#	src/Common.ts
#	src/output_channels/OutputChannelDriverFunctions.ts
  • Loading branch information
Taitava committed Feb 22, 2022
2 parents 973ff17 + 3703c56 commit 1adb7e0
Show file tree
Hide file tree
Showing 5 changed files with 247 additions and 3 deletions.
51 changes: 50 additions & 1 deletion src/Common.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import {App, Editor, FileSystemAdapter, MarkdownView, normalizePath} from "obsidian";
import {
App,
Editor,
EditorPosition,
FileSystemAdapter,
MarkdownView,
normalizePath,
} from "obsidian";
import {PlatformId} from "./settings/SC_MainSettings";
import {platform} from "os";
import * as path from "path";
Expand Down Expand Up @@ -164,6 +171,48 @@ export function generateObsidianCommandName(plugin: SC_Plugin, shell_command: st
return prefix + shell_command;
}

export function isInteger(value: string, allow_minus: boolean): boolean {
if (allow_minus) {
return !!value.match(/^-?\d+$/);
} else {
return !!value.match(/^\d+$/);
}
}

/**
* Translates 1-indexed caret line and column to a 0-indexed EditorPosition object. Also translates a possibly negative line
* to a positive line from the end of the file, and a possibly negative column to a positive column from the end of the line.
* @param editor
* @param caret_line
* @param caret_column
*/
export function prepareEditorPosition(editor: Editor, caret_line: number, caret_column: number): EditorPosition {
// Determine line
if (caret_line < 0) {
// Negative line means to calculate it from the end of the file.
caret_line = Math.max(0, editor.lastLine() + caret_line + 1);
} else {
// Positive line needs just a small adjustment.
// Editor line is zero-indexed, line numbers are 1-indexed.
caret_line -= 1;
}

// Determine column
if (caret_column < 0) {
// Negative column means to calculate it from the end of the line.
caret_column = Math.max(0, editor.getLine(caret_line).length + caret_column + 1);
} else {
// Positive column needs just a small adjustment.
// Editor column is zero-indexed, column numbers are 1-indexed.
caret_column -= 1;
}

return {
line: caret_line,
ch: caret_column,
}
}

export function getSelectionFromTextarea(textarea_element: HTMLTextAreaElement, return_null_if_empty: true): string | null;
export function getSelectionFromTextarea(textarea_element: HTMLTextAreaElement, return_null_if_empty: false): string;
export function getSelectionFromTextarea(textarea_element: HTMLTextAreaElement, return_null_if_empty: boolean): string | null {
Expand Down
2 changes: 1 addition & 1 deletion src/output_channels/OutputChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Designed additional values for later: "specific-file-top" | "specific-file-bottom" | "specific-file-caret" (if possible)
* See discussion: https://github.com/Taitava/obsidian-shellcommands/discussions/16
*/
export type OutputChannel = "ignore" | "notification" | "current-file-caret" | "current-file-top" | "current-file-bottom" | "status-bar" | "clipboard" | "modal";
export type OutputChannel = "ignore" | "notification" | "current-file-caret" | "current-file-top" | "current-file-bottom" | "status-bar" | "clipboard" | "modal" | "open-files";

export type OutputChannelOrder = "stdout-first" | "stderr-first";

Expand Down
5 changes: 5 additions & 0 deletions src/output_channels/OutputChannelDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export abstract class OutputChannelDriver {
* Human readable name, used in settings.
*/
protected abstract readonly title: string;
protected readonly accepted_output_streams: OutputStream[] = ["stdout", "stderr"];

protected plugin: SC_Plugin;
protected app: App;
Expand Down Expand Up @@ -52,6 +53,10 @@ export abstract class OutputChannelDriver {
debugLog("Output handling is done.")
}

public acceptsOutputStream(output_stream: OutputStream) {
return this.accepted_output_streams.contains(output_stream);
}

/**
* Can be moved to a global function isOutputStreamEmpty() if needed.
* @param output
Expand Down
8 changes: 7 additions & 1 deletion src/output_channels/OutputChannelDriverFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {OutputChannelDriver_CurrentFileBottom} from "./OutputChannelDriver_Curre
import {OutputChannelDriver_Clipboard} from "./OutputChannelDriver_Clipboard";
import {ParsingResult, TShellCommand} from "../TShellCommand";
import {OutputChannelDriver_Modal} from "./OutputChannelDriver_Modal";
import {OutputChannelDriver_OpenFiles} from "./OutputChannelDriver_OpenFiles";

export interface OutputStreams {
stdout?: string;
Expand All @@ -26,6 +27,7 @@ registerOutputChannelDriver("notification", new OutputChannelDriver_Notification
registerOutputChannelDriver("current-file-caret", new OutputChannelDriver_CurrentFileCaret());
registerOutputChannelDriver("current-file-top", new OutputChannelDriver_CurrentFileTop());
registerOutputChannelDriver("current-file-bottom", new OutputChannelDriver_CurrentFileBottom());
registerOutputChannelDriver("open-files", new OutputChannelDriver_OpenFiles());
registerOutputChannelDriver("clipboard", new OutputChannelDriver_Clipboard());
registerOutputChannelDriver("modal", new OutputChannelDriver_Modal());

Expand Down Expand Up @@ -135,7 +137,11 @@ export function getOutputChannelDriversOptionList(output_stream: OutputStream) {
[key: string]: string;
} = {ignore: "Ignore"};
for (const name in output_channel_drivers) {
list[name] = output_channel_drivers[name].getTitle(output_stream);
const output_channel_driver: any = output_channel_drivers[name];
// Check that the stream is suitable for the channel
if (output_channel_driver.acceptsOutputStream(output_stream)) {
list[name] = output_channel_driver.getTitle(output_stream);
}
}
return list;
}
Expand Down
184 changes: 184 additions & 0 deletions src/output_channels/OutputChannelDriver_OpenFiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import {OutputChannelDriver} from "./OutputChannelDriver";
import {OutputStreams} from "./OutputChannelDriverFunctions";
import {OutputStream} from "./OutputChannel";
import {
EditorSelectionOrCaret,
normalizePath,
} from "obsidian";
import {
getEditor,
getVaultAbsolutePath,
isInteger,
isWindows,
prepareEditorPosition,
} from "../Common";
import * as path from "path";
import {EOL} from "os";

export class OutputChannelDriver_OpenFiles extends OutputChannelDriver {
protected readonly title = "Open a file";

/**
* This output channel is not suitable for stderr, as stderr can contain unexpected messages.
* @protected
*/
protected readonly accepted_output_streams: OutputStream[] = ["stdout"];

protected _handle(output: OutputStreams, error_code: number | null): void {
let output_stream_name: OutputStream;
for (output_stream_name in output) {
// Get parts that define different details about how the file should be opened
const file_definition = output[output_stream_name].trim(); // Contains at least a file name, and MAYBE: a caret position, new pane option, and view state
const file_definition_parts = file_definition.split(":");

// Future compatibility: Ensure there is no newline characters in-between the output.
// This is to reserve newline usage to future when this output channel will support opening multiple files at once.
// TODO: Remove this check when multi-file support is implemented.
if (file_definition.match(/[\r\n]/)) {
// Bad, the output contains a newline.
this.plugin.newErrors([
"Cannot open file: The output contains linebreaks: " + file_definition,
"Linebreaks will be supported in a future version of SC that allows defining multiple files to open at once.",
]);
return;
}

// The first part is always the file path
let open_file_path = file_definition_parts.shift();

// On Windows: Check if an absolute path was split incorrectly. (E.g. a path starting with "C:\...").
if (isWindows() && file_definition_parts.length > 0) {
const combined_path = open_file_path + ":" + file_definition_parts[0];
if (path.isAbsolute(combined_path)) {
// Yes, the first two parts do form an absolute path together, so they should not be split.
open_file_path = combined_path;
file_definition_parts.shift(); // Remove the second part so that it won't be accidentally processed in the 'Special features' part.
}
}

// Trim the file path, for being able to use cleaner separation between file name and other parts, e.g: MyFile.md : new-pane
open_file_path = open_file_path.trim();

// Special features
const caret_parts: number[] = []; // If caret position is present in file_definition_parts, the first item in this array will be the caret line, the second will be the column. If more parts are present, they will be used for making selections.
let new_pane: boolean = false;
let can_create_file: boolean = false;
let file_definition_interpreting_failed = false;

file_definition_parts.forEach((file_definition_part: string) => {
file_definition_part = file_definition_part.toLocaleLowerCase().trim(); // .trim() is for being able to use cleaner separation between e.g. different selections: MyFile.md:1:1:1:-1 : 5:1:5:-1

// Determine the part type
if (isInteger(file_definition_part, true)) {
// This is a number, so consider it as a caret position part.
caret_parts.push(parseInt(file_definition_part));
} else {
switch (file_definition_part) {
case "new-pane":
new_pane = true;
break;
case "can-create-file":
can_create_file = true;
break;
default:
this.plugin.newError("Cannot open file: Unrecognised definition part: " + file_definition_part + " in " + file_definition);
file_definition_interpreting_failed = true;
}
}
});
if (file_definition_interpreting_failed) {
return;
}

// Ensure the path is relative
if (path.isAbsolute(open_file_path)) {
// The path is absolute.
// Check if it can be converted to relative.
let vault_absolute_path: string = getVaultAbsolutePath(this.app);
if (open_file_path.toLocaleLowerCase().startsWith(vault_absolute_path.toLocaleLowerCase())) {
// Converting to relative is possible
open_file_path = open_file_path.substr(vault_absolute_path.length); // Get everything after the point where the vault path ends.
} else {
// Cannot convert to relative, because the file does not reside in the vault
this.plugin.newError("Cannot open file '" + open_file_path + "' as the path is outside this vault.")
return;
}
}

// Clean up the file path
open_file_path = normalizePath(open_file_path); // normalizePath() is used on purpose, instead of normalizePath2(), because backslashes \ should be converted to forward slashes /

this.openFileInTab(open_file_path, new_pane, can_create_file).then(() => {
// The file is now open
// Check, did we have a caret position available. If not, do nothing.
let count_caret_parts: number = caret_parts.length;
if (count_caret_parts > 0) {
// Yes, a caret position was defined in the output.

// Ensure the correct amount of caret position parts.
// 0 parts: no caret positioning needs to be done (but in this part of code the amount of parts is always greater than 0).
// 1 part: caret line is defined, no column.
// 2 parts: caret line and column are defined.
// 3 parts: NOT ALLOWED.
// 4 parts: selection starting position (line, column) and selection end position (line, column) are defined.
// 5 parts or more: NOT ALLOWED. Exception: any number of sets of four parts is allowed, i.e. 8 parts, 12 parts, 16 parts etc. are allowed as they can define multiple selections.
const error_message_base: string = "File opened, but caret cannot be positioned due to an incorrect amount (" + count_caret_parts + ") of numeric values in the output: " + file_definition + EOL + EOL;
if (count_caret_parts == 3) {
// Incorrect amount of caret parts
this.plugin.newError(error_message_base + "Three numeric parts is an incorrect amount, correct would be 1,2 or 4 parts.");
return;
} else if (count_caret_parts > 4 && count_caret_parts % 4 !== 0) {
// Incorrect amount of caret parts
this.plugin.newError(error_message_base + "Perhaps too many numeric parts are defined? If more than four parts are defined, make sure to define complete sets of four parts. The amount of numeric parts needs to be dividable by 4.");
return;
}

// Even though the file is already loaded, rendering it may take some time, thus the height of the content may increase.
// For this reason, there needs to be a tiny delay before setting the caret position. If the caret position is set immediately,
// the caret will be placed in a correct position, but it might be that the editor does not scroll into correct position, so the
// caret might be out of the view, even when it's in a correct place. (Obsidian version 0.13.23).
window.setTimeout(() => {
const editor = getEditor(this.app)
if (editor) {
if (count_caret_parts >= 4) {
// Selection mode
// There can be multiple selections defined
const selections: EditorSelectionOrCaret[] = [];
while (caret_parts.length) {
const from_line = caret_parts.shift();
const from_column = caret_parts.shift();
const to_line = caret_parts.shift();
const to_column = caret_parts.shift();
selections.push({
anchor: prepareEditorPosition(editor, from_line, from_column),
head: prepareEditorPosition(editor, to_line, to_column),
})
}
editor.setSelections(selections);
} else {
// Simple caret mode
const caret_line: number = caret_parts[0];
const caret_column: number = caret_parts[1] ?? 1;
editor.setCursor(prepareEditorPosition(editor, caret_line, caret_column));
}
}
}, 500); // 500ms is probably long enough even if a new tab is opened (takes more time than opening a file into an existing tab). This can be made into a setting sometime. If you change this, remember to change it in the documentation, too.
}
});
}
}

private openFileInTab(file_path: string, new_pane: boolean, can_create_file: boolean): Promise<void> {
// Ensure that the file exists (or can be created)
const source_path = ""; // TODO: When adding an option for creating new files, read this documentation from Obsidian API's getNewFileParent(): "sourcePath – The path to the current open/focused file, used when the user wants new files to be created “in the same folder”. Use an empty string if there is no active file."
const file_exists_or_can_be_created = can_create_file || null !== this.app.metadataCache.getFirstLinkpathDest(file_path, source_path);
if (file_exists_or_can_be_created) {
// Yes, the file exists (or can be created)
return this.app.workspace.openLinkText(file_path, source_path, new_pane);
} else {
// No, the file does not exist, and it may not be created.
this.plugin.newError("Cannot open file '" + file_path + "', as it does not exist. (If you want to allow file creation, add :can-create-file to the shell command output.)");
}
}

}

0 comments on commit 1adb7e0

Please sign in to comment.