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

Merged
merged 20 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from 10 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
16 changes: 14 additions & 2 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 @@ -878,7 +877,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 @@ -944,4 +943,17 @@ export default class IBMiContent {
async countFiles(directory: string) {
return Number((await this.ibmi.sendCommand({ command: `ls | wc -l`, directory })).stdout.trim());
}

/**
* 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);
}
}
}
22 changes: 19 additions & 3 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,11 +177,11 @@ export namespace Tools {
*/
export function qualifyPath(library: string, object: string, member?: string, iasp?: string, sanitise?: 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}`;

const result = (iasp && iasp.length > 0 ? `/${iasp}` : ``) + `/${libraryPath}/${sanitise ? subPath : Tools.escapePath(subPath)}`;
const result = (iasp && iasp.length > 0 ? `/${iasp}` : ``) + `/${libraryPath}${subPath && sanitise ? subPath : Tools.escapePath(subPath)}`;
return result;
}

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 @@ -389,4 +392,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;
}
}
2 changes: 1 addition & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export async function activate(context: ExtensionContext): Promise<CodeForIBMi>
}
}
}),
workspace.registerFileSystemProvider(`streamfile`, new IFSFS(), {
workspace.registerFileSystemProvider(`streamfile`, new IFSFS(context), {
isCaseSensitive: false
}),
languages.registerCompletionItemProvider({ language: 'json', pattern: "**/.vscode/actions.json" }, new LocalActionCompletionItemProvider(), "&")
Expand Down
26 changes: 26 additions & 0 deletions src/filesystems/fileStatCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import vscode from "vscode";

export class FileStatCache {
private readonly cache: Map<string, vscode.FileStat | null> = new Map

set(uri: vscode.Uri | string, stat: vscode.FileStat | null) {
this.cache.set(toPath(uri), stat);
}

get(uri: vscode.Uri | string) {
return this.cache.get(toPath(uri));
}

clear(uri?: vscode.Uri | string) {
if (uri) {
this.cache.delete(toPath(uri));
}
else {
this.cache.clear();
}
}
}

function toPath(uri: vscode.Uri | string) {
return typeof uri === "string" ? uri : uri.path;
}
117 changes: 100 additions & 17 deletions src/filesystems/ifsFs.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
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 { FileStatCache } from "./fileStatCache";
import { getFilePermission } from "./qsys/QSysFs";

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

private readonly statCache = new FileStatCache();

constructor(context: vscode.ExtensionContext) {
instance.onEvent("disconnected", () => { this.statCache.clear() });
context.subscriptions.push(
vscode.workspace.onDidCloseTextDocument((doc) => this.statCache.clear(doc.uri)),
vscode.commands.registerCommand("code-for-ibmi.clearIFSStats", (uri?: vscode.Uri | string) => this.statCache.clear(uri))
);
}

watch(uri: vscode.Uri, options: { readonly recursive: boolean; readonly excludes: readonly string[]; }): vscode.Disposable {
return { dispose: () => { } };
}
Expand All @@ -18,7 +30,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 +39,110 @@ 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> {
let currentStat = this.statCache.get(uri);
if (currentStat === undefined) {
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) {
currentStat = {
ctime: Tools.parseAttrDate(String(attributes.CREATE_TIME)),
mtime: Tools.parseAttrDate(String(attributes.MODIFY_TIME)),
size: Number(attributes.DATA_SIZE),
type: String(attributes.OBJTYPE) === "*DIR" ? vscode.FileType.Directory : vscode.FileType.File,
permissions: getFilePermission(uri)
}
}
}
if (currentStat) {
this.statCache.set(uri, currentStat);
} else {
this.statCache.set(uri, null);
throw FileSystemError.FileNotFound(uri);
}
}
else {
currentStat = {
ctime: 0,
mtime: 0,
size: 0,
type: vscode.FileType.File,
permissions: getFilePermission(uri)
}
}
}
else if (currentStat === null) {
throw FileSystemError.FileNotFound(uri);
}

return currentStat;
}

async writeFile(uri: vscode.Uri, content: Uint8Array, options: { readonly create: boolean; readonly overwrite: boolean; }) {
const path = uri.path;
const exists = this.statCache.get(path);
this.statCache.clear(path);
const contentApi = instance.getContent();
if (contentApi) {
contentApi.writeStreamfileRaw(uri.path, content);
if (!content.length) { //Coming from "Save as"
await contentApi.createStreamFile(path);
}
else {
await contentApi.writeStreamfileRaw(path, content);
}
if (!exists) {
vscode.commands.executeCommand(`code-for-ibmi.refreshIFSBrowser`);
}
}
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> {
this.statCache.clear(destination);
}

rename(oldUri: vscode.Uri, newUri: vscode.Uri, options: { readonly overwrite: boolean; }): void | Thenable<void> {
console.log({ oldUri, newUri, options });
this.statCache.clear(oldUri);
this.statCache.clear(newUri);
}

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) {
this.statCache.clear(uri);
}
else {
throw FileSystemError.NoPermissions(result.stderr);
}
}
}
}

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