file-address.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. /**
  2. * The `dsh-resource://file/…` address grammar: how a file is named across the
  3. * Sidebar and the resource model, built and parsed without touching a
  4. * filesystem.
  5. * @module
  6. */
  7. /**
  8. * A file resource address, in one of two scopes.
  9. *
  10. * Every resource address is `dsh-resource://<type>/…`, the URI host naming the
  11. * resource protocol; for `file` the path opens with the scope:
  12. *
  13. * - `dsh-resource://file/session/<sessionId>/<path>` names a file by its path,
  14. * relative to that Session's workspace root or absolute; the
  15. * Host resolves it against the root it holds for the Session.
  16. * - `dsh-resource://file/absolute/<path>` names a file by its absolute path with
  17. * the leading `/` dropped (`dsh-resource://file/absolute/home/ys/notes.txt`;
  18. * Windows `dsh-resource://file/absolute/C:/x/y.txt`; a UNC path keeps an empty
  19. * first segment, `dsh-resource://file/absolute//server/share/x.txt`). It carries
  20. * no Session.
  21. *
  22. * Every id and path segment is component-encoded, so a name carrying `#`, `?`,
  23. * or a space survives the round trip; `:` stays literal so a drive letter reads
  24. * as written.
  25. */
  26. export type FileAddress =
  27. | {
  28. readonly scope: 'session'
  29. /** The Session whose Host workspace resolves the path. */
  30. readonly sessionId: string
  31. /** Absolute or workspace-relative `/`-separated path; empty for the workspace root itself. */
  32. readonly path: string
  33. }
  34. | {
  35. readonly scope: 'absolute'
  36. /** Absolute `/`-separated path: `/a/b` on POSIX, `C:/a/b` for a Windows drive, `//server/share/a` for a UNC path. */
  37. readonly path: string
  38. }
  39. /** The scheme and type every file address opens with. */
  40. const FILE_ADDRESS_PREFIX = 'dsh-resource://file/'
  41. /** Component-encode one id or path segment, keeping `:` literal for drive letters. */
  42. function encodeSegment(segment: string): string {
  43. return encodeURIComponent(segment).replace(/%3A/gi, ':')
  44. }
  45. /** Encode a `/`-separated path segment by segment. */
  46. function encodePath(path: string): string {
  47. return path.split('/').map(encodeSegment).join('/')
  48. }
  49. /** Whether a decoded first path segment is a Windows drive (`C:`). */
  50. function isDriveSegment(segment: string | undefined): boolean {
  51. return segment !== undefined && /^[A-Za-z]:$/.test(segment)
  52. }
  53. /**
  54. * Build the address of a file read through one Session.
  55. * @param sessionId - the Session whose Host workspace resolves the path.
  56. * @param path - absolute or workspace-relative path; backslashes are normalized to `/`, and leading `./` prefixes are dropped.
  57. * @returns the `dsh-resource://file/session/<sessionId>/<path>` address.
  58. */
  59. export function sessionFileAddress(sessionId: string, path: string): string {
  60. const normalized = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '')
  61. return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(normalized)}`
  62. }
  63. /**
  64. * Build the address of a file by its absolute path.
  65. * @param path - absolute path; backslashes are normalized to `/` and the leading `/` is dropped,
  66. * except that a UNC path (`\\server\share`) keeps one empty first segment.
  67. * @returns the `dsh-resource://file/absolute/<path>` address.
  68. */
  69. export function absoluteFileAddress(path: string): string {
  70. const normalized = path.replace(/\\/g, '/')
  71. const unc = normalized.startsWith('//')
  72. const absolute = normalized.replace(/^\/+/, '')
  73. return `${FILE_ADDRESS_PREFIX}absolute/${unc ? '/' : ''}${encodePath(absolute)}`
  74. }
  75. /**
  76. * Read a file address back into its parts without resolving `.` or `..`.
  77. * Query and fragment suffixes are ignored; encoded path segments are decoded.
  78. * @param address - a candidate address.
  79. * @returns the parts, or `undefined` when the string is not a `dsh-resource://file/` URI in a known scope with a path, or a segment is not validly encoded.
  80. */
  81. export function parseFileAddress(address: string): FileAddress | undefined {
  82. try {
  83. if (!address.startsWith(FILE_ADDRESS_PREFIX)) return undefined
  84. const end = address.search(/[?#]/)
  85. const [scope, ...rest] = address.slice(FILE_ADDRESS_PREFIX.length, end === -1 ? undefined : end).split('/')
  86. if (scope === 'session') {
  87. const [id, ...segments] = rest
  88. if (id === undefined || id === '' || segments.length === 0) return undefined
  89. return { scope, sessionId: decodeURIComponent(id), path: segments.map(decodeURIComponent).join('/') }
  90. }
  91. if (scope === 'absolute') {
  92. // An empty first segment with more behind it is a UNC path's `//`; alone it is no path.
  93. const unc = rest[0] === '' && rest.length > 1
  94. const segments = (unc ? rest.slice(1) : rest).map(decodeURIComponent)
  95. if (segments.length === 0 || segments[0] === '') return undefined
  96. if (unc) return { scope, path: `//${segments.join('/')}` }
  97. return { scope, path: isDriveSegment(segments[0]) ? segments.join('/') : `/${segments.join('/')}` }
  98. }
  99. return undefined
  100. } catch {
  101. // `decodeURIComponent` throws URIError on a malformed escape.
  102. return undefined
  103. }
  104. }