term244: viewer split pane + Windows port roadmap
Integrate the HTML/Markdown viewer into the terminal split tree as a pane. SurfaceView gains a viewer mode (no pty/surface), so a viewer rides in the existing SplitTree<SurfaceView> with no leaf-type change. - Open Viewer in Split via menu and CLI (open -a term244 file.md) - reuse an existing viewer pane instead of creating new ones - close button on viewer panes - persistent WKWebView so closing one pane does not flicker others Also adds WINDOWS_PORT.md, a roadmap for the native Windows port. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>pull/12772/head
parent
b8a3e24cae
commit
ee28ddd56e
|
|
@ -0,0 +1,112 @@
|
|||
# term244 — ネイティブ Windows 移植ロードマップ
|
||||
|
||||
term244(Ghostty フォーク)をネイティブ Windows で動かすための実装計画。
|
||||
macOS 版(リブランド + HTML/Markdown ビューアタブ/ペイン)は完成済み。
|
||||
Windows 版はここから着手する。
|
||||
|
||||
> このドキュメントは Windows マシン側で作業を再開するための土台。
|
||||
> macOS のチャットセッションでは Windows のビルド・検証ができないため、
|
||||
> 実装そのものは Windows 環境で進めること。
|
||||
|
||||
## 現状サマリ
|
||||
|
||||
- **コア(プラットフォーム非依存)**: `src/terminal/`(VT パース・画面バッファ)、
|
||||
`src/Surface.zig`、`src/App.zig`。Windows でもそのまま動く。
|
||||
- **apprt(アプリ層)**: `src/apprt/` に `gtk`(Linux)・`embedded`(macOS の
|
||||
Swift アプリが利用)・`none` のみ。**Windows 用 apprt は存在しない** ← 最大の不足。
|
||||
- **レンダラ**: `src/renderer/` に Metal(macOS)と OpenGL(Linux/GTK)。
|
||||
|
||||
## 既に Windows で動く / 再利用できるもの
|
||||
|
||||
下回りはかなり揃っている。足りないのは「ガワ(GUI)」だけ。
|
||||
|
||||
| 要素 | 状態 | 場所 |
|
||||
|---|---|---|
|
||||
| PTY (ConPTY) | ✅ 実装済み | `src/pty.zig` の `WindowsPty`(`CreatePseudoConsole` 等) |
|
||||
| プロセス起動 | ✅ 実装済み | `src/Command.zig` の `startWindows()`(`CreateProcessW` + ConPTY) |
|
||||
| Win32 API シム | ✅ あり | `src/os/windows.zig` |
|
||||
| MSVC ABI | ✅ ビルド設定済み | `src/build/Config.zig`(Windows で `abi = .msvc` 強制) |
|
||||
| ターミナルコア | ✅ 非依存 | `src/terminal/`、`src/Surface.zig`、`src/App.zig` |
|
||||
| Windows リソース雛形 | ✅ あり | `dist/windows/ghostty.rc` |
|
||||
|
||||
## 不足しているもの(新規実装)
|
||||
|
||||
1. **Windows apprt** — ウィンドウ生成・メッセージループ・入力・DPI・
|
||||
クリップボード・IPC。`src/apprt/gtk/` が参照実装(Surface だけで ~1500 行)。
|
||||
2. **OpenGL の WGL コンテキスト経路** — `src/renderer/OpenGL.zig` の
|
||||
`surfaceInit`/`threadEnter`/`threadExit`/`displayRealized` が現状
|
||||
GTK/embedded 専用 switch。Windows(WGL)ケースを追加。
|
||||
3. **apprt 選択の配線** — `src/apprt.zig` / `src/apprt/runtime.zig` /
|
||||
`build.zig` に `.windows` を追加。
|
||||
4. **フォント探索** — fontconfig は Linux 専用。Windows は DirectWrite で
|
||||
フォント列挙(`src/font/`)。ラスタライズは FreeType を流用可。
|
||||
5. **単一インスタンス IPC** — D-Bus の代わりに名前付きパイプ or Mutex。
|
||||
|
||||
## 実装計画(フェーズ順)
|
||||
|
||||
### W1. apprt 選択の配線(小)
|
||||
- `src/apprt.zig` の runtime 選択 switch に `.windows => windows` を追加。
|
||||
- `src/apprt/runtime.zig` の `Runtime.default()` で Windows を `.windows` に。
|
||||
- `build.zig`: Windows ターゲットで `exe` アーティファクトを許可。
|
||||
- まず空の `src/apprt/windows.zig` を作り、コンパイルが通る骨格にする。
|
||||
|
||||
### W2. Windows apprt の骨格(大 — 本丸)
|
||||
- 新規: `src/apprt/windows.zig`、`src/apprt/windows/App.zig`、
|
||||
`src/apprt/windows/Surface.zig`。
|
||||
- `App`: Win32 メッセージループ(`GetMessage`/`DispatchMessage`)、
|
||||
ウィンドウクラス登録。
|
||||
- `Surface`: `CreateWindowExW` でウィンドウ生成、`WndProc` で入力
|
||||
(`WM_KEYDOWN`/`WM_CHAR`/`WM_MOUSE*`)・リサイズ(`WM_SIZE`)・
|
||||
描画(`WM_PAINT`)・DPI(`WM_DPICHANGED`)を処理しコアへ転送。
|
||||
- 参照: `src/apprt/gtk/App.zig` / `src/apprt/gtk/Surface.zig` と
|
||||
`src/apprt/embedded.zig`(コアが apprt に求めるメソッド一覧の把握に最適)。
|
||||
- apprt が実装すべき最小メソッド: `deinit`、`core()`、`getTitle()`、
|
||||
`getContentScale()`、`getSize()`、`getCursorPos()`、`supportsClipboard()`、
|
||||
`clipboardRequest()`、`setClipboard()`、`defaultTermioEnv()` ほかコールバック群。
|
||||
|
||||
### W3. レンダラ(OpenGL + WGL)
|
||||
- `src/renderer/OpenGL.zig` の apprt 別 switch に `.windows` ケースを追加。
|
||||
- Surface の `HWND` → `HDC` 取得 → `wglCreateContext`(または
|
||||
`WGL_ARB_create_context` で 3.3+ コア)→ レンダラスレッドで `wglMakeCurrent`。
|
||||
- `displayRealized` 相当を Windows apprt 側から呼んで GL ロード
|
||||
(`gl.glad.load`、proc は `wglGetProcAddress`)。
|
||||
- D3D 新規バックエンドは不要。OpenGL で足りる。
|
||||
|
||||
### W4. フォント(DirectWrite)
|
||||
- `src/font/backend.zig` に Windows 用ディスカバリを追加。`IDWriteFactory`
|
||||
でシステムフォント列挙。ラスタライズは既存 FreeType を流用可。
|
||||
|
||||
### W5. クリップボード / IPC / 仕上げ
|
||||
- クリップボード: `OpenClipboard`/`GetClipboardData`/`SetClipboardData`
|
||||
(`CF_UNICODETEXT`)。
|
||||
- 単一インスタンス: 名前付き Mutex + 名前付きパイプで2個目の起動を転送。
|
||||
- `dist/windows/ghostty.rc` を term244 用に調整。アイコン。パッケージング。
|
||||
|
||||
## 推奨着手順とマイルストーン
|
||||
|
||||
W1(配線・骨格)→ W2(空ウィンドウが出る所まで)→ W3(GL でターミナル描画)
|
||||
→ **ここで「文字が出るターミナル」= 最初のマイルストーン** → W4/W5 で実用度向上。
|
||||
|
||||
## ビルド & 実行(Windows)
|
||||
|
||||
- Zig 0.15.2(`build.zig.zon` の `minimum_zig_version`)。
|
||||
- W1 で `windows` apprt を追加後:
|
||||
`zig build -Dtarget=x86_64-windows-msvc -Dapp-runtime=windows`
|
||||
- 当面は `zig build` で `.exe` を生成 → 直接実行。
|
||||
|
||||
## 参考資料
|
||||
|
||||
- **ConPTY の使い方**: Microsoft Windows Terminal リポジトリ(C++)。
|
||||
`CreatePseudoConsole` とパイプ管理の正典。term244 側は実装済みなので
|
||||
主に「考え方」の参照。
|
||||
- **apprt が実装すべき契約**: `src/apprt/embedded.zig`(C-ABI 版・境界が明快)、
|
||||
`src/apprt/gtk/`(フル apprt の実例)。
|
||||
- **OpenGL on Windows**: `WGL_ARB_create_context`、glad のロード。
|
||||
|
||||
## 難所・リスク
|
||||
|
||||
- Win32 のメッセージループとコアのイベントループ(libxev)の統合。
|
||||
- IME(`WM_IME_*`)対応 — 日本語入力に必須、地味に重い。
|
||||
- DPI スケーリング(per-monitor v2)。
|
||||
- `src/os/` に残る Unix 前提コードの個別対応(都度 `builtin.os.tag` で分岐)。
|
||||
- 規模: W2 が最大。GTK apprt 同等で数千行。全体で数週間〜の独立プロジェクト。
|
||||
|
|
@ -212,7 +212,7 @@ class AppDelegate: NSObject,
|
|||
// Initial config loading
|
||||
ghosttyConfigDidChange(config: ghostty.config)
|
||||
|
||||
// Add the "Open Viewer Tab…" item to the File menu, right after "New Tab".
|
||||
// Add the viewer items to the File menu, right after "New Tab".
|
||||
if let newTabItem = menuNewTab, let fileMenu = newTabItem.menu {
|
||||
let viewerItem = NSMenuItem(
|
||||
title: "Open Viewer Tab…",
|
||||
|
|
@ -220,6 +220,13 @@ class AppDelegate: NSObject,
|
|||
keyEquivalent: "")
|
||||
viewerItem.target = self
|
||||
fileMenu.insertItem(viewerItem, at: fileMenu.index(of: newTabItem) + 1)
|
||||
|
||||
let splitItem = NSMenuItem(
|
||||
title: "Open Viewer in Split…",
|
||||
action: #selector(openViewerSplit(_:)),
|
||||
keyEquivalent: "")
|
||||
splitItem.target = self
|
||||
fileMenu.insertItem(splitItem, at: fileMenu.index(of: viewerItem) + 1)
|
||||
}
|
||||
|
||||
// Start our update checker.
|
||||
|
|
@ -468,13 +475,17 @@ class AppDelegate: NSObject,
|
|||
var isDirectory = ObjCBool(true)
|
||||
guard FileManager.default.fileExists(atPath: filename, isDirectory: &isDirectory) else { return false }
|
||||
|
||||
// HTML and Markdown files open in a viewer tab rather than executing.
|
||||
// HTML and Markdown files open in a viewer rather than executing.
|
||||
// If a terminal window is open, render in a split pane; otherwise open
|
||||
// a standalone viewer tab/window.
|
||||
if !isDirectory.boolValue {
|
||||
let url = URL(fileURLWithPath: filename)
|
||||
if ViewerController.supportedExtensions.contains(url.pathExtension.lowercased()) {
|
||||
ViewerController.open(
|
||||
fileURL: url,
|
||||
from: TerminalController.preferredParent?.window)
|
||||
if let controller = TerminalController.preferredParent {
|
||||
controller.showViewer(url: url)
|
||||
} else {
|
||||
ViewerController.open(fileURL: url, from: nil)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -998,6 +1009,25 @@ class AppDelegate: NSObject,
|
|||
}
|
||||
}
|
||||
|
||||
/// Opens a file picker and renders the chosen HTML/Markdown file in a split
|
||||
/// pane beside the focused terminal.
|
||||
@IBAction func openViewerSplit(_ sender: Any?) {
|
||||
guard let controller = TerminalController.preferredParent else {
|
||||
// No terminal window to split; fall back to a viewer tab.
|
||||
openViewerTab(sender)
|
||||
return
|
||||
}
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.message = "Choose an HTML or Markdown file to view in a split"
|
||||
panel.begin { response in
|
||||
guard response == .OK, let url = panel.url else { return }
|
||||
controller.showViewer(url: url)
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func closeAllWindows(_ sender: Any?) {
|
||||
TerminalController.closeAllWindows()
|
||||
AboutController.shared.hide()
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import SwiftUI
|
|||
enum TerminalSplitOperation {
|
||||
case resize(Resize)
|
||||
case drop(Drop)
|
||||
case close(Ghostty.SurfaceView)
|
||||
|
||||
struct Resize {
|
||||
let node: SplitTree<Ghostty.SurfaceView>.Node
|
||||
|
|
@ -95,6 +96,26 @@ private struct TerminalSplitLeaf: View {
|
|||
@State private var isSelfDragging: Bool = false
|
||||
|
||||
var body: some View {
|
||||
if let viewerURL = surfaceView.viewerFileURL {
|
||||
// Non-terminal viewer pane: render the HTML/Markdown file.
|
||||
ViewerWebView(fileURL: viewerURL, persistentHost: surfaceView)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
Button {
|
||||
action(.close(surfaceView))
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(6)
|
||||
.background(.regularMaterial, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(8)
|
||||
.help("Close viewer pane")
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Viewer pane")
|
||||
} else {
|
||||
GeometryReader { geometry in
|
||||
Ghostty.InspectableSurface(
|
||||
surfaceView: surfaceView,
|
||||
|
|
@ -128,6 +149,7 @@ private struct TerminalSplitLeaf: View {
|
|||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Terminal pane")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum DropState: Equatable {
|
||||
|
|
|
|||
|
|
@ -269,6 +269,51 @@ class BaseTerminalController: NSWindowController,
|
|||
return newView
|
||||
}
|
||||
|
||||
/// Create a new non-terminal viewer split rendering the HTML/Markdown file
|
||||
/// at `url`, beside `oldView`.
|
||||
@discardableResult
|
||||
func newViewerSplit(
|
||||
at oldView: Ghostty.SurfaceView,
|
||||
direction: SplitTree<Ghostty.SurfaceView>.NewDirection = .right,
|
||||
url: URL
|
||||
) -> Ghostty.SurfaceView? {
|
||||
// We can only create new splits for surfaces in our tree.
|
||||
guard surfaceTree.root?.node(view: oldView) != nil else { return nil }
|
||||
|
||||
// Create a viewer surface (no terminal/pty is created).
|
||||
let newView = Ghostty.SurfaceView(viewerFile: url)
|
||||
|
||||
let newTree: SplitTree<Ghostty.SurfaceView>
|
||||
do {
|
||||
newTree = try surfaceTree.inserting(
|
||||
view: newView,
|
||||
at: oldView,
|
||||
direction: direction)
|
||||
} catch {
|
||||
Ghostty.logger.warning("failed to insert viewer split: \(error)")
|
||||
return nil
|
||||
}
|
||||
|
||||
replaceSurfaceTree(
|
||||
newTree,
|
||||
moveFocusTo: newView,
|
||||
moveFocusFrom: oldView,
|
||||
undoAction: "New Viewer Split")
|
||||
|
||||
return newView
|
||||
}
|
||||
|
||||
/// Show `url` in this window's viewer pane: reuse an existing viewer pane
|
||||
/// if there is one, otherwise create a new viewer split.
|
||||
func showViewer(url: URL) {
|
||||
if let existing = surfaceTree.first(where: { $0.viewerFileURL != nil }) {
|
||||
existing.setViewerFile(url)
|
||||
return
|
||||
}
|
||||
guard let target = focusedSurface ?? surfaceTree.root?.leftmostLeaf() else { return }
|
||||
newViewerSplit(at: target, url: url)
|
||||
}
|
||||
|
||||
/// Move focus to a surface view.
|
||||
func focusSurface(_ view: Ghostty.SurfaceView) {
|
||||
// Check if target surface is in our tree
|
||||
|
|
@ -879,6 +924,8 @@ class BaseTerminalController: NSWindowController,
|
|||
splitDidResize(node: resize.node, to: resize.ratio)
|
||||
case .drop(let drop):
|
||||
splitDidDrop(source: drop.payload, destination: drop.destination, zone: drop.zone)
|
||||
case .close(let view):
|
||||
closeSurface(view, withConfirmation: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,17 @@ struct ViewerView: View {
|
|||
struct ViewerWebView: NSViewRepresentable {
|
||||
let fileURL: URL
|
||||
|
||||
/// When set (split-pane usage), the web view is cached on the surface so it
|
||||
/// survives SwiftUI rebuilds of the split tree. nil for standalone tabs.
|
||||
var persistentHost: Ghostty.SurfaceView? = nil
|
||||
|
||||
func makeNSView(context: Context) -> WKWebView {
|
||||
// Reuse a cached web view so content does not reload (flicker) when the
|
||||
// split tree rebuilds, e.g. after another pane is closed.
|
||||
if let cached = persistentHost?.viewerWebView as? WKWebView {
|
||||
return cached
|
||||
}
|
||||
|
||||
let config = WKWebViewConfiguration()
|
||||
// A viewer never needs cookies or localStorage; a non-persistent store
|
||||
// skips disk I/O on init.
|
||||
|
|
@ -24,7 +34,8 @@ struct ViewerWebView: NSViewRepresentable {
|
|||
|
||||
let webView = WKWebView(frame: .zero, configuration: config)
|
||||
webView.allowsMagnification = true
|
||||
load(into: webView)
|
||||
ViewerWebView.load(url: fileURL, into: webView)
|
||||
persistentHost?.viewerWebView = webView
|
||||
return webView
|
||||
}
|
||||
|
||||
|
|
@ -32,19 +43,21 @@ struct ViewerWebView: NSViewRepresentable {
|
|||
// The file URL is fixed for the lifetime of the view; nothing to do.
|
||||
}
|
||||
|
||||
private func load(into webView: WKWebView) {
|
||||
let ext = fileURL.pathExtension.lowercased()
|
||||
let dir = fileURL.deletingLastPathComponent()
|
||||
/// Renders `url` into `webView`: Markdown is converted to HTML, everything
|
||||
/// else (HTML, etc.) is loaded directly.
|
||||
static func load(url: URL, into webView: WKWebView) {
|
||||
let ext = url.pathExtension.lowercased()
|
||||
let dir = url.deletingLastPathComponent()
|
||||
|
||||
switch ext {
|
||||
case "md", "markdown", "mdown", "mkd", "mkdn":
|
||||
let text = (try? String(contentsOf: fileURL, encoding: .utf8))
|
||||
?? "# Could not read file\n\n`\(fileURL.path)`"
|
||||
let text = (try? String(contentsOf: url, encoding: .utf8))
|
||||
?? "# Could not read file\n\n`\(url.path)`"
|
||||
webView.loadHTMLString(ViewerHTML.markdownPage(markdown: text), baseURL: dir)
|
||||
default:
|
||||
// html, htm, or anything else: render the file directly. Grant
|
||||
// read access to the directory so relative assets resolve.
|
||||
webView.loadFileURL(fileURL, allowingReadAccessTo: dir)
|
||||
webView.loadFileURL(url, allowingReadAccessTo: dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import Combine
|
|||
import SwiftUI
|
||||
import CoreText
|
||||
import UserNotifications
|
||||
import WebKit
|
||||
import GhosttyKit
|
||||
|
||||
extension Ghostty {
|
||||
|
|
@ -167,6 +168,17 @@ extension Ghostty {
|
|||
override var surface: ghostty_surface_t? {
|
||||
surfaceModel?.unsafeCValue
|
||||
}
|
||||
|
||||
/// When non-nil, this is a non-terminal "viewer" pane that renders the
|
||||
/// HTML/Markdown file at this URL instead of a terminal. Viewer panes
|
||||
/// have no `surfaceModel` / pty; terminal code paths that guard on
|
||||
/// `surface` simply no-op for them.
|
||||
private(set) var viewerFileURL: URL?
|
||||
|
||||
/// Persistent web view for a viewer pane, retained here so the rendered
|
||||
/// content survives SwiftUI rebuilds of the split tree (e.g. when
|
||||
/// another pane is closed). Typed as `NSView` to avoid a WebKit import.
|
||||
var viewerWebView: NSView?
|
||||
/// Current scrollbar state, cached here for persistence across rebuilds
|
||||
/// of the SwiftUI view hierarchy, for example when changing splits
|
||||
var scrollbar: Ghostty.Action.Scrollbar?
|
||||
|
|
@ -358,6 +370,38 @@ extension Ghostty {
|
|||
registerForDraggedTypes(Array(Self.dropTypes))
|
||||
}
|
||||
|
||||
/// Creates a non-terminal "viewer" surface that renders the HTML or
|
||||
/// Markdown file at `url`. No terminal surface or pty is created, so
|
||||
/// this is a lightweight leaf for the split tree.
|
||||
init(viewerFile url: URL, uuid: UUID? = nil) {
|
||||
self.viewerFileURL = url
|
||||
self.markedText = NSMutableAttributedString()
|
||||
|
||||
if let appDelegate = NSApplication.shared.delegate as? AppDelegate {
|
||||
self.derivedConfig = DerivedConfig(appDelegate.ghostty.config)
|
||||
} else {
|
||||
self.derivedConfig = DerivedConfig()
|
||||
}
|
||||
|
||||
self.cachedScreenContents = .init(duration: .milliseconds(500)) { "" }
|
||||
self.cachedVisibleContents = self.cachedScreenContents
|
||||
|
||||
super.init(id: uuid, frame: NSRect(x: 0, y: 0, width: 800, height: 600))
|
||||
|
||||
// The pane/tab title is the file name.
|
||||
self.title = url.lastPathComponent
|
||||
}
|
||||
|
||||
/// Updates this viewer pane to render a different file, reloading the
|
||||
/// cached web view in place (no new pane is created).
|
||||
func setViewerFile(_ url: URL) {
|
||||
self.viewerFileURL = url
|
||||
self.title = url.lastPathComponent
|
||||
if let webView = viewerWebView as? WKWebView {
|
||||
ViewerWebView.load(url: url, into: webView)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported for this view")
|
||||
}
|
||||
|
|
@ -1799,6 +1843,7 @@ extension Ghostty {
|
|||
case uuid
|
||||
case title
|
||||
case isUserSetTitle
|
||||
case viewerURL
|
||||
}
|
||||
|
||||
required convenience init(from decoder: Decoder) throws {
|
||||
|
|
@ -1811,6 +1856,13 @@ extension Ghostty {
|
|||
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let uuid = UUID(uuidString: try container.decode(String.self, forKey: .uuid))
|
||||
|
||||
// Viewer panes are non-terminal; recreate them directly.
|
||||
if let viewerPath = try container.decodeIfPresent(String.self, forKey: .viewerURL) {
|
||||
self.init(viewerFile: URL(fileURLWithPath: viewerPath), uuid: uuid)
|
||||
return
|
||||
}
|
||||
|
||||
var config = Ghostty.SurfaceConfiguration()
|
||||
config.workingDirectory = try container.decode(String?.self, forKey: .pwd)
|
||||
let savedTitle = try container.decodeIfPresent(String.self, forKey: .title)
|
||||
|
|
@ -1830,6 +1882,7 @@ extension Ghostty {
|
|||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encodeIfPresent(viewerFileURL?.path, forKey: .viewerURL)
|
||||
try container.encode(pwd, forKey: .pwd)
|
||||
try container.encode(id.uuidString, forKey: .uuid)
|
||||
try container.encode(title, forKey: .title)
|
||||
|
|
|
|||
Loading…
Reference in New Issue