Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow Save As and breadcrumb navigation #2060

Open
wants to merge 20 commits into
base: master
Choose a base branch
from
Open
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
25 changes: 12 additions & 13 deletions src/api/IBMi.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import * as node_ssh from "node-ssh";
import * as vscode from "vscode";
import { ConnectionConfiguration } from "./Configuration";

import { parse } from 'csv-parse/sync';
import { existsSync } from "fs";
import * as node_ssh from "node-ssh";
import os from "os";
import path from 'path';
import path, { parse as parsePath } from 'path';
import * as vscode from "vscode";
import { ComponentId, ComponentManager } from "../components/component";
import { CopyToImport } from "../components/copyToImport";
import { instance } from "../instantiate";
import { CommandData, CommandResult, ConnectionData, IBMiMember, RemoteCommand, SpecialAuthorities, WrapResult } from "../typings";
import { CompileTools } from "./CompileTools";
import { ConnectionConfiguration } from "./Configuration";
import IBMiContent from "./IBMiContent";
import { CachedServerSettings, GlobalStorage } from './Storage';
import { Tools } from './Tools';
Expand Down Expand Up @@ -1180,20 +1179,21 @@ export default class IBMi {
}
}

parserMemberPath(string: string): MemberParts {
parserMemberPath(string: string, checkExtension?: boolean): MemberParts {
const variant_chars_local = this.variantChars.local;
const validQsysName = new RegExp(`^[A-Z0-9${variant_chars_local}][A-Z0-9_${variant_chars_local}.]{0,9}$`);

// Remove leading slash
const upperCasedString = this.upperCaseName(string);
const path = upperCasedString.startsWith(`/`) ? upperCasedString.substring(1).split(`/`) : upperCasedString.split(`/`);

const basename = path[path.length - 1];
const parsedPath = parsePath(upperCasedString);
const name = parsedPath.name;
const file = path[path.length - 2];
const library = path[path.length - 3];
const asp = path[path.length - 4];

if (!library || !file || !basename) {
if (!library || !file || !name) {
throw new Error(`Invalid path: ${string}. Use format LIB/SPF/NAME.ext`);
}
if (asp && !validQsysName.test(asp)) {
Expand All @@ -1206,12 +1206,10 @@ export default class IBMi {
throw new Error(`Invalid Source File name: ${file}`);
}

//Having a blank extension is allowed but the . in the path is required
if (!basename.includes(`.`)) {
//Having a blank extension is allowed but the . in the path is required if checking the extension
if (checkExtension && !parsedPath.ext) {
throw new Error(`Source Type extension is required.`);
}
const name = basename.substring(0, basename.lastIndexOf(`.`));
const extension = basename.substring(basename.lastIndexOf(`.`) + 1).trim();

if (!validQsysName.test(name)) {
throw new Error(`Invalid Source Member name: ${name}`);
Expand All @@ -1221,6 +1219,7 @@ export default class IBMi {
// the existing RegExp because result.extension is everything after
// the final period (so we know it won't contain a period).
// But, a blank extension is valid.
const extension = parsedPath.ext.substring(1);
if (extension && !validQsysName.test(extension)) {
throw new Error(`Invalid Source Member Extension: ${extension}`);
}
Expand All @@ -1229,7 +1228,7 @@ export default class IBMi {
library,
file,
extension,
basename,
basename: parsedPath.base,
name,
asp
};
Expand Down
23 changes: 18 additions & 5 deletions src/api/IBMiContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ export type SortOptions = {
}

export default class IBMiContent {
private chgJobCCSID: string | undefined = undefined;
constructor(readonly ibmi: IBMi) { }

private get config(): ConnectionConfiguration.Parameters {
Expand Down Expand Up @@ -828,7 +827,7 @@ export default class IBMiContent {

// This can error if the path format is wrong for some reason.
// Not that this would ever happen, but better to be safe than sorry
return this.ibmi.parserMemberPath(simplePath);
return this.ibmi.parserMemberPath(simplePath, true);
} catch (e) {
console.log(e);
}
Expand Down Expand Up @@ -907,7 +906,7 @@ export default class IBMiContent {
})).code === 0;
}

async testStreamFile(path: string, right: "f" | "d" | "r" | "w" | "x") {
async testStreamFile(path: string, right: "e" | "f" | "d" | "r" | "w" | "x") {
return (await this.ibmi.sendCommand({ command: `test -${right} ${Tools.escapePath(path)}` })).code === 0;
}

Expand Down Expand Up @@ -952,8 +951,9 @@ export default class IBMiContent {
return cl;
}

async getAttributes(path: string | (QsysPath & { member?: string }), ...operands: AttrOperands[]) {
const target = (path = typeof path === 'string' ? Tools.escapePath(path) : Tools.qualifyPath(path.library, path.name, path.member, path.asp));
async getAttributes(path: string | (QsysPath & { member?: string }), ...operands: AttrOperands[]) {
const target = (path = typeof path === 'string' ? Tools.escapePath(path) : this.ibmi.sysNameInAmerican(Tools.qualifyPath(path.library, path.name, path.member, path.asp)));

const result = await this.ibmi.sendCommand({ command: `${this.ibmi.remoteFeatures.attr} -p ${target} ${operands.join(" ")}` });
if (result.code === 0) {
return result.stdout
Expand Down Expand Up @@ -1022,4 +1022,17 @@ export default class IBMiContent {
tooltip.supportHtml = true;
return tooltip;
}

/**
* Creates an empty unicode streamfile
* @param path the full path to the streamfile
* @throws an Error if the file could not be correctly created
*/
async createStreamFile(path: string) {
path = Tools.escapePath(path);
const result = (await this.ibmi.sendCommand({ command: `echo "" > ${path} && ${this.ibmi.remoteFeatures.attr} ${path} CCSID=1208` }));
if (result.code !== 0) {
throw new Error(result.stderr);
}
}
}
20 changes: 18 additions & 2 deletions src/api/Tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { t } from "../locale";
import { IBMiMessage, IBMiMessages, QsysPath } from '../typings';
import { API, GitExtension } from "./import/git";

const MONTHS = [undefined, "Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const DAYS = [undefined,"Mon","Tue","Wed","Thu","Fri","Sat","Sun"];

export namespace Tools {
export class SqlError extends Error {
public sqlstate: string = "0";
Expand Down Expand Up @@ -174,7 +177,7 @@ export namespace Tools {
*/
export function qualifyPath(library: string, object: string, member?: string, iasp?: string, noEscape?: boolean) {
const libraryPath = library === `QSYS` ? `QSYS.LIB` : `QSYS.LIB/${Tools.sanitizeLibraryNames([library]).join(``)}.LIB`;
const filePath = `${object}.FILE`;
const filePath = object ? `${object}.FILE` : '';
const memberPath = member ? `/${member}.MBR` : '';
const subPath = `${filePath}${memberPath}`;

Expand Down Expand Up @@ -272,7 +275,7 @@ export namespace Tools {
}

export function parseQSysPath(path: string): QsysPath {
const parts = path.split('/');
const parts = path.split('/').filter(Boolean);
if (parts.length > 3) {
return {
asp: parts[0],
Expand Down Expand Up @@ -426,4 +429,17 @@ export namespace Tools {
}
}
}

/**
* Converts a timestamp from the attr command (in the form `Thu Dec 21 21:47:02 2023`) into a Date object
* @param timestamp an attr timestamp string
* @returns a Date object
*/
export function parseAttrDate(timestamp:string){
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sebjulliand This code is worrying me a bit - you get the attributes using the PASE command attr, which can return IBM i object attributes, but it writes the dates in text, which you then have to parse.

The timestamps output from attr are dependent on the locale, which may vary from system to system - or even user by user.

Wouldn't it be better to get the attributes for an IFS object using the SQL function IFS_OBJECT_STATISTICS? It can return the same information and returns the timestamps in ISO format - or as EPOCH, if it is specified in the SQL statement. Like we do with member lists...

Then this parse function is irrelevant - and the month name at the top etc.

?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, I tried (hard) to use IFS_OBJECT_STATISTICS, but it doesn't give any information if you don't have enough authority to do so. Every column will be null in this case. For example, there are some files on PUB400 that I can access in read-only mode. For those, IFS_OBJECT_STATISTICS will not give me any information.

I tried on a few different LPARS and even when I change LC_TIME before calling attr, it still returns the dates using the en_US locale.

So for now, I'll take the risk of using attr and parse the dates empirically. 😅

const parts = /^([\w]{3}) ([\w]{3}) +([\d]+) ([\d]+:[\d]+:[\d]+) ([\d]+)$/.exec(timestamp);
if(parts){
return Date.parse(`${parts[3].padStart(2, "0")} ${parts[2]} ${parts[5]} ${parts[4]} GMT`);
}
return 0;
}
}
89 changes: 71 additions & 18 deletions src/filesystems/ifsFs.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import vscode from "vscode";
import { getFilePermission } from "./qsys/QSysFs";
import vscode, { FileSystemError, FileType } from "vscode";
import { Tools } from "../api/Tools";
import { instance } from "../instantiate";
import { getFilePermission } from "./qsys/QSysFs";

export class IFSFS implements vscode.FileSystemProvider {
private readonly savedAsFiles: Set<string> = new Set;
private emitter = new vscode.EventEmitter<vscode.FileChangeEvent[]>();
onDidChangeFile: vscode.Event<vscode.FileChangeEvent[]> = this.emitter.event;

Expand All @@ -18,7 +20,7 @@ export class IFSFS implements vscode.FileSystemProvider {
}
else {
if (retrying) {
throw new Error("Not connected to IBM i");
throw new FileSystemError("Not connected to IBM i");
}
else {
await vscode.commands.executeCommand(`code-for-ibmi.connectToPrevious`);
Expand All @@ -27,39 +29,90 @@ export class IFSFS implements vscode.FileSystemProvider {
}
}

stat(uri: vscode.Uri): vscode.FileStat {
return {
ctime: 0,
mtime: 0,
size: 0,
type: vscode.FileType.File,
permissions: getFilePermission(uri)
async stat(uri: vscode.Uri): Promise<vscode.FileStat> {
const content = instance.getContent();
if (content) {
const path = uri.path;
if (await content.testStreamFile(path, "e")) {
const attributes = await content.getAttributes(path, "CREATE_TIME", "MODIFY_TIME", "DATA_SIZE", "OBJTYPE");
if (attributes) {
const type = String(attributes.OBJTYPE) === "*DIR" ? vscode.FileType.Directory : vscode.FileType.File;
return {
ctime: Tools.parseAttrDate(String(attributes.CREATE_TIME)),
mtime: Tools.parseAttrDate(String(attributes.MODIFY_TIME)),
size: Number(attributes.DATA_SIZE),
type,
permissions: !this.savedAsFiles.has(path) && type !== FileType.Directory ? getFilePermission(uri) : undefined
}
}
}
throw FileSystemError.FileNotFound(uri);
}
else {
return {
ctime: 0,
mtime: 0,
size: 0,
type: vscode.FileType.File,
permissions: getFilePermission(uri)
}
}
}

async writeFile(uri: vscode.Uri, content: Uint8Array, options: { readonly create: boolean; readonly overwrite: boolean; }) {
const path = uri.path;
const contentApi = instance.getContent();
if (contentApi) {
contentApi.writeStreamfileRaw(uri.path, content);
if (!content.length) { //Coming from "Save as"
this.savedAsFiles.add(path);
await contentApi.createStreamFile(path);
vscode.commands.executeCommand(`code-for-ibmi.refreshIFSBrowser`);
}
else {
this.savedAsFiles.delete(path);
await contentApi.writeStreamfileRaw(path, content);
}
}
else {
throw new Error("Not connected to IBM i");
throw new FileSystemError("Not connected to IBM i");
}
}

copy(source: vscode.Uri, destination: vscode.Uri, options: { readonly overwrite: boolean; }): void | Thenable<void> {
//not used at the moment
}

rename(oldUri: vscode.Uri, newUri: vscode.Uri, options: { readonly overwrite: boolean; }): void | Thenable<void> {
console.log({ oldUri, newUri, options });
//not used at the moment
}

readDirectory(uri: vscode.Uri): [string, vscode.FileType][] | Thenable<[string, vscode.FileType][]> {
throw new Error(`readDirectory not implemented in IFSFS.`);
async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> {
const content = instance.getContent();
if (content) {
return (await content.getFileList(uri.path)).map(ifsFile => ([ifsFile.name, ifsFile.type === "directory" ? FileType.Directory : FileType.File]));
}
else {
throw new FileSystemError("Not connected to IBM i");
}
}

createDirectory(uri: vscode.Uri): void | Thenable<void> {
throw new Error(`createDirectory not implemented in IFSFS.`);
async createDirectory(uri: vscode.Uri) {
const connection = instance.getConnection();
if (connection) {
const path = uri.path;
if (await connection.content.testStreamFile(path, "d")) {
throw FileSystemError.FileExists(uri);
}
else {
const result = await connection.sendCommand({ command: `mkdir -p ${path}` });
if (result.code !== 0) {
throw FileSystemError.NoPermissions(result.stderr);
}
}
}
}

delete(uri: vscode.Uri, options: { readonly recursive: boolean; }): void | Thenable<void> {
throw new Error(`delete not implemented in IFSFS.`);
throw new FileSystemError(`delete not implemented in IFSFS.`);
}
}
Loading